diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 56920f06c..08f78c180 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.3.0" +version = "0.3.1" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -12,6 +12,7 @@ dependencies = [ "uipath-core>=0.5.34, <0.6.0", "pydantic-function-models>=0.1.11", "sqlparse>=0.6.0, <0.7.0", + "pyyaml>=6.0", ] classifiers = [ "Intended Audience :: Developers", @@ -48,6 +49,7 @@ dev = [ "pytest-cov>=4.1.0", "pytest-mock>=3.11.1", "pre-commit>=4.1.0", + "types-PyYAML>=6.0", ] [tool.hatch.build.targets.wheel] diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index 52f392e66..1e7106daf 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -40,6 +40,7 @@ from .pii_detection import PiiDetectionService from .resource_catalog import ResourceCatalogService from .semantic_proxy import SemanticProxyService +from .skills import SkillsService def _has_valid_client_credentials( @@ -203,6 +204,10 @@ def pii_detection(self) -> PiiDetectionService: def semantic_proxy(self) -> SemanticProxyService: return SemanticProxyService(self._config, self._execution_context) + @cached_property + def skills(self) -> SkillsService: + return SkillsService(self._config, self._execution_context) + @property def automation_tracker(self) -> AutomationTrackerService: return AutomationTrackerService(self._config, self._execution_context) diff --git a/packages/uipath-platform/src/uipath/platform/common/__init__.py b/packages/uipath-platform/src/uipath/platform/common/__init__.py index 2d39e561b..d41226c1d 100644 --- a/packages/uipath-platform/src/uipath/platform/common/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/common/__init__.py @@ -14,6 +14,7 @@ ResourceOverwrite, ResourceOverwriteParser, ResourceOverwritesContext, + SkillBundleResourceOverwrite, resource_override, ) from ._config import UiPathApiConfig, UiPathConfig, resolve_coded_agenthub_config @@ -132,6 +133,7 @@ "ResourceOverwrite", "ResourceOverwriteParser", "ResourceOverwritesContext", + "SkillBundleResourceOverwrite", "ReferenceEntry", "ReferenceContext", "ReferenceContextAccessor", diff --git a/packages/uipath-platform/src/uipath/platform/common/_bindings.py b/packages/uipath-platform/src/uipath/platform/common/_bindings.py index 73d80fe38..64d0a04a5 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_bindings.py +++ b/packages/uipath-platform/src/uipath/platform/common/_bindings.py @@ -20,6 +20,7 @@ ConfigDict, Field, TypeAdapter, + field_validator, model_validator, ) @@ -121,9 +122,65 @@ def folder_identifier(self) -> str: return self.folder_key +class SkillBundleResourceOverwrite(ResourceOverwrite): + """A deploy-time override of an agent's skill bundle binding. + + Like the other bindings, an entry replaces the whole binding: it carries + ``name``, ``folderPath`` and ``versionMode``, plus ``version`` when pinned. + Its ``name`` retargets the bundle that is looked up; the agent-side name of + the binding stays what the agent declared. Apply it with + ``uipath.platform.skills._overwrites.apply_skill_bundle_overwrite``. + """ + + resource_type: Literal["skillBundle"] + name: str = Field(alias="name") + folder_path: str = Field(alias="folderPath") + # Keep in sync with uipath.platform.skills.VersionMode. + version_mode: Literal["pinned", "latest"] = Field(alias="versionMode") + version: Optional[str] = Field(default=None, alias="version") + + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + @field_validator("name", "folder_path") + @classmethod + def _not_blank(cls, value: str) -> str: + if not value.strip(): + raise ValueError( + "a skillBundle overwrite needs a non-blank name and folderPath" + ) + return value + + @field_validator("version", mode="before") + @classmethod + def _blank_is_unset(cls, value: Any) -> Any: + # A cleared form field arrives as "", which is no version. + if isinstance(value, str) and not value.strip(): + return None + return value + + @model_validator(mode="after") + def _version_matches_mode(self) -> "SkillBundleResourceOverwrite": + if self.version_mode == "latest": + self.version = None + elif not self.version: + raise ValueError("a pinned skillBundle overwrite needs a version") + return self + + @property + def resource_identifier(self) -> str: + return self.name + + @property + def folder_identifier(self) -> str: + return self.folder_path + + ResourceOverwriteUnion = Annotated[ Union[ - GenericResourceOverwrite, EntityResourceOverwrite, ConnectionResourceOverwrite + GenericResourceOverwrite, + EntityResourceOverwrite, + ConnectionResourceOverwrite, + SkillBundleResourceOverwrite, ], Field(discriminator="resource_type"), ] diff --git a/packages/uipath-platform/src/uipath/platform/skills/__init__.py b/packages/uipath-platform/src/uipath/platform/skills/__init__.py new file mode 100644 index 000000000..3976574f0 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/skills/__init__.py @@ -0,0 +1,38 @@ +"""Skill bundles for coded agents: references, resolved bundles, the manifest, and the service that loads them.""" + +from ._constants import MANIFEST_FILE_NAME +from ._skills_service import SkillBundleLoadError, SkillsService +from ._sources import ( + BundleSourceError, + LocalFolderBundleSource, + OrchestratorBundleSource, + SkillBundleSource, +) +from .skills import ( + BundleFailure, + FailureReason, + InvokedBy, + Skill, + SkillBundle, + SkillBundleRef, + SkillSet, + VersionMode, +) + +__all__ = [ + "BundleFailure", + "BundleSourceError", + "FailureReason", + "InvokedBy", + "LocalFolderBundleSource", + "MANIFEST_FILE_NAME", + "OrchestratorBundleSource", + "Skill", + "SkillBundle", + "SkillBundleLoadError", + "SkillBundleRef", + "SkillBundleSource", + "SkillSet", + "SkillsService", + "VersionMode", +] diff --git a/packages/uipath-platform/src/uipath/platform/skills/_constants.py b/packages/uipath-platform/src/uipath/platform/skills/_constants.py new file mode 100644 index 000000000..883f90fbc --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/skills/_constants.py @@ -0,0 +1,29 @@ +"""Names shared by the skills package and its call sites.""" + +SKILLS_DIR_NAME = "skills" +"""Folder that holds skill bundles, in a project and in an agent's virtual filesystem.""" + +MANIFEST_FILE_NAME = ".uipath-skills-manifest.json" + +OVERWRITE_TYPE = "skillBundle" +"""Prefix of a bundle entry in ``resourceOverwrites`` (``skillBundle.``).""" + +BUNDLE_FILE_NAME = "bundle.json" +SKILL_FILE_NAME = "SKILL.md" +MAX_FILE_BYTES = 512 * 1024 +MAX_BUNDLE_BYTES = 24 * 1024 * 1024 +BUNDLE_RESOLVE_TIMEOUT_SECONDS = 20.0 +"""Per-bundle wall clock for resolving skills at agent start. + +After the first timeout the remaining bundles are skipped. The local source +copies on a worker thread, so it times out too, stopping before its next file. +""" + +LOCAL_VERSION = "local" +"""The version of a bundle read from a folder without a ``bundle.json`` version.""" + +DEFAULT_SOURCE_KIND = "local" +"""The source used when nothing selects one: bundles read from a local folder.""" + +ENV_SKILLS_SOURCE = "UIPATH_SKILLS_SOURCE" +ENV_SKILLS_LOCAL_ROOT = "UIPATH_SKILLS_LOCAL_ROOT" diff --git a/packages/uipath-platform/src/uipath/platform/skills/_layout.py b/packages/uipath-platform/src/uipath/platform/skills/_layout.py new file mode 100644 index 000000000..c446a517f --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/skills/_layout.py @@ -0,0 +1,126 @@ +"""The on-disk bundle layout, ``//SKILL.md`` plus an optional ``bundle.json``.""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path + +from ._constants import BUNDLE_FILE_NAME, LOCAL_VERSION, SKILL_FILE_NAME + +logger = logging.getLogger(__name__) + + +def read_bundle_version(bundle_dir: Path) -> str: + """The ``version`` from ``bundle.json``, as a string. + + Args: + bundle_dir: The bundle folder. + + Returns: + The version, or ``LOCAL_VERSION`` when ``bundle.json`` is missing, + unreadable, not a JSON object, or has an empty or null ``version``. + """ + try: + data = json.loads((bundle_dir / BUNDLE_FILE_NAME).read_text(encoding="utf-8")) + except FileNotFoundError: + return LOCAL_VERSION + except (ValueError, OSError) as e: + logger.warning( + "Ignoring unreadable %s in %s: %s", BUNDLE_FILE_NAME, bundle_dir, e + ) + return LOCAL_VERSION + if not isinstance(data, dict): + logger.warning( + "Ignoring %s in %s: not a JSON object", BUNDLE_FILE_NAME, bundle_dir + ) + return LOCAL_VERSION + version = data.get("version") + if version is None or version == "": + return LOCAL_VERSION + return str(version) + + +def is_plain_name(name: str) -> bool: + r"""Whether ``name`` is one visible path segment. + + Args: + name: A file or folder name, or one segment of a relative path. + + Returns: + ``False`` when it is empty, hidden (which covers ``.`` and ``..``), or + contains a separator, a drive colon or a NUL byte (``/``, ``\``, ``:``, + ``\0``). A NUL is refused because ``Path.resolve`` raises ``ValueError`` + for it on Windows, which no caller expects from a path. + """ + return ( + bool(name) + and not name.startswith(".") + and not any(c in name for c in "/\\:\x00") + ) + + +def is_skill_md(path: str) -> bool: + """Whether ``path``, relative to a skill folder, names its ``SKILL.md`` in any case. + + Args: + path: A file path relative to the skill folder, in POSIX form. + + Returns: + ``True`` for ``SKILL.md``, ``skill.md``, ``Skill.md`` and so on, at the + top of the skill folder only. + """ + return path.casefold() == SKILL_FILE_NAME.casefold() + + +def find_skill_md(path: Path) -> Path | None: + """The ``SKILL.md`` of a skill folder, matched case-insensitively. + + A skill folder is a plain-named folder holding ``SKILL.md`` in any case + (``skill.md`` counts). On a case-sensitive file system it can hold several + spellings; ``SKILL.md`` itself wins, then the first in code-point order. + + Args: + path: A child of a bundle folder. + + Returns: + The file, or ``None`` when ``path`` is not a skill folder. + """ + if not path.is_dir() or not is_plain_name(path.name): + return None + matches = [p for p in path.iterdir() if is_skill_md(p.name) and p.is_file()] + return min(matches, key=lambda p: (p.name != SKILL_FILE_NAME, p.name), default=None) + + +def skill_md_first(path: str) -> tuple[bool, str]: + """Sort key that puts ``SKILL.md``, in any case, before the other files of a skill. + + Args: + path: A file path relative to the skill folder, in POSIX form. + + Returns: + A key that orders ``SKILL.md`` first and the rest alphabetically. + """ + return (not is_skill_md(path), path) + + +def iter_skill_files(skill_dir: Path) -> list[str]: + """The files of a skill, relative to ``skill_dir`` in POSIX form. + + Args: + skill_dir: A skill folder. + + Returns: + Sorted paths with ``SKILL.md`` first; a path with any component that is + not a plain name (hidden files and folders, for instance) is left out. + """ + return sorted( + ( + rel.as_posix() + for rel in ( + p.relative_to(skill_dir) for p in skill_dir.rglob("*") if p.is_file() + ) + if all(is_plain_name(part) for part in rel.parts) + ), + key=skill_md_first, + ) diff --git a/packages/uipath-platform/src/uipath/platform/skills/_overwrites.py b/packages/uipath-platform/src/uipath/platform/skills/_overwrites.py new file mode 100644 index 000000000..214dfe24c --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/skills/_overwrites.py @@ -0,0 +1,85 @@ +"""Deploy-time overrides for skill bundles, read from the SDK's resource-overwrite context.""" + +from __future__ import annotations + +import logging +from typing import Mapping + +from pydantic import ValidationError + +from ..common._bindings import ( + ResourceOverwrite, + SkillBundleResourceOverwrite, + _resource_overwrites, +) +from ._constants import OVERWRITE_TYPE +from .skills import SkillBundleRef, VersionMode + +logger = logging.getLogger(__name__) + + +def current_overwrites() -> Mapping[str, ResourceOverwrite] | None: + """The resource overwrites active for this run, if a context installed them. + + Returns: + The map ``ResourceOverwritesContext`` set, keyed + ``.[.]``, or ``None`` outside such a context. + """ + return _resource_overwrites.get() + + +def apply_skill_bundle_overwrite( + ref: SkillBundleRef, overwrites: Mapping[str, ResourceOverwrite] | None +) -> SkillBundleRef: + """The reference with its ``skillBundle.[.]`` override applied. + + ``skillBundle..`` wins over ``skillBundle.``. The + override replaces the folder and version of the declared reference; its + ``name`` only changes the lookup target (``source_name``), so ``ref.name`` + stays the agent-side identity. An override that yields an invalid + reference is ignored with a warning. + + Args: + ref: The bound bundle as the agent declares it. + overwrites: The active overwrite map, usually ``current_overwrites()``. + + Returns: + A new reference built from the override, or ``ref`` itself when + nothing matches, the map is empty, or the reference is invalid. + """ + if not overwrites: + return ref + candidates = ( + [f"{OVERWRITE_TYPE}.{ref.name}.{ref.folder_path}"] if ref.folder_path else [] + ) + candidates.append(f"{OVERWRITE_TYPE}.{ref.name}") + for key in candidates: + overwrite = overwrites.get(key) + if not isinstance(overwrite, SkillBundleResourceOverwrite): + continue + try: + return SkillBundleRef( + name=ref.name, + source_name=None if overwrite.name == ref.name else overwrite.name, + folder_path=overwrite.folder_path, + version_mode=VersionMode(overwrite.version_mode), + version=overwrite.version, + ) + except ValidationError as e: + message = _first_error(e) + bare_key = f"{OVERWRITE_TYPE}.{ref.name}" + if key != bare_key and bare_key in overwrites: + message += f"; the less specific key '{bare_key}' was not tried" + logger.warning( + "Ignoring invalid skillBundle overwrite '%s' for '%s': %s", + key, + ref.name, + message, + ) + return ref + return ref + + +def _first_error(error: ValidationError) -> str: + """The first validation message on one line, instead of pydantic's report.""" + return str(error.errors()[0]["msg"]) diff --git a/packages/uipath-platform/src/uipath/platform/skills/_skill_md.py b/packages/uipath-platform/src/uipath/platform/skills/_skill_md.py new file mode 100644 index 000000000..719a12e24 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/skills/_skill_md.py @@ -0,0 +1,55 @@ +"""Read the parts of a ``SKILL.md`` the SDK needs.""" + +from __future__ import annotations + +import yaml + +_BOM = "" +_FENCE = "---" + + +def _frontmatter(content: str) -> str | None: + r"""The YAML between the opening and the closing ``---`` line, or ``None``. + + Reads frontmatter as deepagents' SkillsMiddleware (the Agent Skills + convention) does with ``^---\s*\n(.*?)\n---\s*\n``, without that regex, + which backtracks on large inputs. Fence lines may carry trailing + whitespace, ``\r`` included. Blank lines right after the opening fence are + skipped, the first fence line after the first non-blank one closes the + block, and a newline must follow the closing fence. + """ + lines = content.split("\n") + if lines[0].rstrip() != _FENCE: + return None + start = 1 + while start < len(lines) and not lines[start].strip(): + start += 1 + # The last element follows the final newline, so it cannot close. + for end in range(start + 1, len(lines) - 1): + if lines[end].rstrip() == _FENCE: + return "\n".join(lines[start:end]) + return None + + +def parse_skill_description(content: str) -> str: + """The ``description`` from a ``SKILL.md``'s YAML frontmatter. + + Full frontmatter validation is left to the consumer of the skill. + + Args: + content: The ``SKILL.md`` text; a leading UTF-8 BOM is ignored. + + Returns: + The stripped description, or ``""`` when there is no frontmatter, it is + not valid YAML, it is not a mapping, or it has no description. + """ + frontmatter = _frontmatter(content.lstrip(_BOM)) + if frontmatter is None: + return "" + try: + data = yaml.safe_load(frontmatter) + except yaml.YAMLError: + return "" + if not isinstance(data, dict): + return "" + return str(data.get("description", "")).strip() diff --git a/packages/uipath-platform/src/uipath/platform/skills/_skills_service.py b/packages/uipath-platform/src/uipath/platform/skills/_skills_service.py new file mode 100644 index 000000000..8087e2a19 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/skills/_skills_service.py @@ -0,0 +1,602 @@ +"""The SDK service that resolves, downloads and exposes an agent's skill bundles. + +``SkillsService.load_all`` turns the bundle references an agent declares into a +``SkillSet`` on disk: the run's ``skillBundle.`` resource overwrites are +applied first, each bundle is fetched from the configured source under a +per-bundle time budget, and whatever cannot be resolved is reported in +``SkillSet.failures`` and on the ``Skill bundles resolved`` span instead of +failing the run. +""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import contextvars +import logging +import shutil +import tempfile +from collections.abc import Coroutine, Sequence +from pathlib import Path +from typing import Any, TypeVar + +from opentelemetry.trace import Span + +from uipath.platform.common import ( + BaseService, + FolderContext, + UiPathApiConfig, + UiPathExecutionContext, +) + +from ._constants import BUNDLE_RESOLVE_TIMEOUT_SECONDS +from ._overwrites import apply_skill_bundle_overwrite, current_overwrites +from ._sources import ( + BundleSourceError, + SkillBundleSource, + configured_source_kind, + get_bundle_source, +) +from ._telemetry import ( + bundles_resolved_span, + record_bundle_failed, + record_bundles_resolved, +) +from .skills import ( + BundleFailure, + FailureReason, + SkillBundle, + SkillBundleRef, + SkillSet, + VersionMode, +) + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + +_TEMP_PREFIX = "uipath-skills-" +"""Prefix of the temp folder ``load_all`` creates when no destination is given.""" + + +class SkillBundleLoadError(Exception): + """``load``/``load_async`` could not resolve the one bundle it was asked for. + + Raised for every ``BundleFailure.reason``, not only ``not_found``. + """ + + def __init__(self, failure: BundleFailure) -> None: + """Wrap the failure ``load_all`` would have reported. + + Args: + failure: The bundle that could not be resolved, and why. + """ + super().__init__( + f"skill bundle '{failure.ref.name}' {failure.reason}: {failure.detail}" + ) + self.failure = failure + """The reported failure: ``ref``, ``reason`` and ``detail``.""" + + +class SkillsService(FolderContext, BaseService): + """Resolve the skill bundles an agent is bound to and expose their skills to agent code. + + Each bundle is ``pinned`` to a version or follows the ``latest`` one. Deploy-time + overrides (``skillBundle.`` resource overwrites) are applied before fetching. + A bundle that cannot be resolved is reported, never raised, by ``load_all``. + + Where the bundles come from is chosen by ``UIPATH_SKILLS_SOURCE``: a local + folder (the default) or the Orchestrator Skills API, a placeholder until + that API ships. The ``*_async`` methods are preferred; each + synchronous twin runs its async method to completion, on a worker thread + when an event loop is already running in the calling thread. + """ + + def __init__( + self, config: UiPathApiConfig, execution_context: UiPathExecutionContext + ) -> None: + """Create the service. + + Args: + config: Base URL and access token for the Skills API. + execution_context: The job's execution context. + """ + super().__init__(config=config, execution_context=execution_context) + + async def load_all_async( + self, + refs: Sequence[SkillBundleRef], + *, + destination: Path | None = None, + source: SkillBundleSource | None = None, + ) -> SkillSet: + """Resolve ``refs`` in order, write each bundle under ``//`` and describe the set. + + The run's ``skillBundle.`` resource overwrites are applied to each + reference first. Every bundle gets ``BUNDLE_RESOLVE_TIMEOUT_SECONDS`` of + wall clock; after the first timeout the remaining bundles are skipped + without being tried. A bundle that fails is reported in + ``SkillSet.failures`` and as a ``skill.bundle.failed`` event on the + ``Skill bundles resolved`` span; nothing here fails the run, though + cancellation still propagates. The folder the bundles land in holds + only this run's bundles plus the manifest: whatever was in + ``destination`` before is removed first, on every call, before the + source is even built. After an unexpected error a caller + ``destination`` may hold partial files; an owned temp root is always + removed. + + Args: + refs: The bundles the agent is bound to. With none, nothing is + fetched, no span is emitted and the set has no ``root``; a + caller ``destination`` is still reset, so an agent that unbinds + every bundle does not keep last run's files. + destination: The folder to write into; it is reset first and the set + does not own it, so ``SkillSet.cleanup`` leaves it alone. By + default a temp folder is created that the set owns and + ``cleanup`` removes. + source: Where bundles come from; by default the one + ``UIPATH_SKILLS_SOURCE`` selects. It is closed before this + returns. + + Returns: + The resolved bundles and the failures. ``root`` is set only when at + least one bundle resolved; an owned temp folder is removed otherwise. + """ + refs = list(refs) + if not refs: + return await _empty_set(destination, source) + try: + with bundles_resolved_span() as span: + result, root = await self._resolve(span, refs, destination, source) + except Exception as e: + logger.exception( + "Skill bundles could not be materialized; continuing without skills" + ) + return _unexpected_failure(refs, e) + owns_root = destination is None + if not result.bundles or root is None: + if owns_root and root is not None: + _remove_tree(root) + return result + result.root = root + result.owns_root = owns_root + return result + + def load_all( + self, + refs: Sequence[SkillBundleRef], + *, + destination: Path | None = None, + source: SkillBundleSource | None = None, + ) -> SkillSet: + """Resolve and download ``refs``; see ``load_all_async``. + + ``load_all_async`` is preferred. This runs it to completion, on a + worker thread when an event loop is already running in this thread; + the resource-overwrite context of the calling thread still applies. + Called from a running loop, it blocks the caller for up to the + per-bundle budget per bundle. + + Args: + refs: The bundles the agent is bound to. + destination: The folder to write into, reset first and not owned by + the set; by default an owned temp folder. + source: Where bundles come from; by default the one + ``UIPATH_SKILLS_SOURCE`` selects. It is closed before this + returns. + + Returns: + The resolved bundles and the failures. + """ + return _run_sync( + self.load_all_async(refs, destination=destination, source=source) + ) + + async def load_async( + self, + name: str, + *, + folder_path: str | None = None, + version_mode: str | VersionMode = VersionMode.LATEST, + version: str | None = None, + destination: Path | None = None, + ) -> SkillSet: + """Resolve and download one bundle, raising when it cannot be. + + The same resolution as ``load_all_async`` for a single reference, + overwrites included. A bundle is addressed by name and folder path. + The set holds exactly one bundle; use ``read()`` for its files and + ``cleanup()`` when done (a caller-supplied ``destination`` is left + alone). + + Args: + name: The bundle name, as the agent declares it. + folder_path: The bundle's folder path, or ``None`` for the job's + folder. + version_mode: ``"pinned"`` or ``"latest"`` (a ``VersionMode`` or its + value). + version: The version to pin; required for ``pinned``, dropped for + ``latest``. + destination: The folder to write into, reset first; by default a + temp folder. + + Returns: + A set with the one resolved bundle, its ``root`` set. + + Raises: + SkillBundleLoadError: The bundle could not be resolved; its + ``failure`` says why (``not_found``, ``version_not_found``, + ``forbidden``, ``invalid``, ``too_large`` or + ``source_unavailable``). Nothing is left on disk for an owned + temp root. + ValueError: ``version_mode`` is not a known mode, or the reference + is invalid (a name that is not one plain folder name, or + ``pinned`` without a ``version``). + """ + ref = SkillBundleRef( + name=name, + folder_path=folder_path, + version_mode=VersionMode(version_mode), + version=version, + ) + result = await self.load_all_async([ref], destination=destination) + if result.failures: + # With one reference and a failure there is no bundle, so an owned + # temp root is already gone; this keeps that true should it change. + result.cleanup() + raise SkillBundleLoadError(result.failures[0]) + return result + + def load( + self, + name: str, + *, + folder_path: str | None = None, + version_mode: str | VersionMode = VersionMode.LATEST, + version: str | None = None, + destination: Path | None = None, + ) -> SkillSet: + """Resolve and download one bundle; see ``load_async``. + + ``load_async`` is preferred. This runs it to completion, on a worker + thread when an event loop is already running in this thread; called + from a running loop, it blocks the caller for up to the per-bundle + budget. A bundle is addressed by name and folder path. The set holds + exactly one bundle; use ``read()`` for its files and ``cleanup()`` + when done (a caller-supplied ``destination`` is left alone). + + Args: + name: The bundle name, as the agent declares it. + folder_path: The bundle's folder path, or ``None`` for the job's + folder. + version_mode: ``"pinned"`` or ``"latest"``. + version: The version to pin; required for ``pinned``. + destination: The folder to write into, reset first; by default a + temp folder. + + Returns: + A set with the one resolved bundle, its ``root`` set. + + Raises: + SkillBundleLoadError: As ``load_async`` raises it. + ValueError: As ``load_async`` raises it. + """ + return _run_sync( + self.load_async( + name, + folder_path=folder_path, + version_mode=version_mode, + version=version, + destination=destination, + ) + ) + + async def _resolve( + self, + span: Span, + refs: list[SkillBundleRef], + destination: Path | None, + source: SkillBundleSource | None, + ) -> tuple[SkillSet, Path | None]: + """Resolve ``refs`` into ``destination``, or into a temp root created here. + + Returns the set and the root it was written to, ``None`` when no root + could be created. An owned temp root is removed if anything raises. + """ + if destination is not None: + return await self._resolve_into( + span, refs, destination, source + ), destination + try: + root = Path(tempfile.mkdtemp(prefix=_TEMP_PREFIX)) + except OSError as e: + # A full or unwritable temp dir is reported like any other setup + # failure; the run continues without skills. + source_name = _source_name(source) + if source is not None: + await _close_source(source) + failed = _fail_all( + span, + source_name, + refs, + reason="source_unavailable", + detail=f"cannot create the skills folder: {e}", + ) + return failed, None + try: + return await self._resolve_into(span, refs, root, source), root + except BaseException: + _remove_tree(root) + raise + + async def _resolve_into( + self, + span: Span, + refs: list[SkillBundleRef], + root: Path, + source: SkillBundleSource | None, + ) -> SkillSet: + """Reset ``root``, build the source when none was given, materialize, and close the source. + + A caller-supplied ``source`` is closed on every path, a failed reset + included. + """ + try: + # The folder is reserved for materialized bundles, so nothing from + # an earlier run (a bundle no longer bound, a stale manifest) may + # survive. This runs before the source is built, so the reset + # happens even when the source cannot be. + if not _remove_tree(root): + return _fail_all( + span, + _source_name(source), + refs, + reason="invalid", + detail="stale files could not be removed", + ) + if source is None: + # get_bundle_source raises when the configured source cannot be + # built. That is a configuration problem, not a reason to fail + # the run and not a reason to silently read another source + # instead: report every bundle and continue. + try: + source = get_bundle_source(self._config, self._execution_context) + except Exception as e: + return _fail_all( + span, + configured_source_kind(), + refs, + reason="source_unavailable", + detail=f"skills source unavailable: {e!r}", + ) + return await _materialize_with(span, source, refs, root) + finally: + if source is not None: + await _close_source(source) + + +async def _materialize_with( + span: Span, + source: SkillBundleSource, + refs: list[SkillBundleRef], + root: Path, +) -> SkillSet: + """Materialize ``refs`` in order into the reset ``root``, then write the manifest. + + The manifest is written only when at least one bundle resolved; the caller + decides what happens to ``root`` otherwise. + """ + overwrites = current_overwrites() + bundles: list[SkillBundle] = [] + failures: list[BundleFailure] = [] + seen: set[str] = set() + timed_out = False + for declared in refs: + ref = apply_skill_bundle_overwrite(declared, overwrites) + dest = root / ref.name + failure: BundleFailure | None = None + if ref.name.casefold() in seen or dest.exists(): + # A second reference would write into the first one's folder and + # shadow or mix its skills; the first reference wins. ``root`` was + # reset, so an existing ``dest`` is an earlier bundle of this run + # under a name the filesystem folds together with this one (case, + # or Unicode normalization) that ``casefold`` did not catch. + failure = BundleFailure( + ref=ref, + reason="invalid", + detail="repeated bundle name; the first reference was kept", + ) + elif timed_out: + failure = BundleFailure( + ref=ref, + reason="source_unavailable", + detail="skipped: skills source timed out", + ) + else: + bundle, failure, timed_out = await _materialize_one(source, ref, dest) + if bundle is not None: + bundles.append(bundle) + seen.add(ref.name.casefold()) + if failure is not None: + failures.append(failure) + record_bundle_failed(span, failure) + _warn_duplicate_skill_names(bundles) + result = SkillSet(source=source.name, bundles=bundles, failures=failures) + record_bundles_resolved(span, result) + if bundles: + result.save(root) + return result + + +async def _materialize_one( + source: SkillBundleSource, ref: SkillBundleRef, dest: Path +) -> tuple[SkillBundle | None, BundleFailure | None, bool]: + """One bundle under the time budget: the bundle, or its failure and whether the budget ran out. + + A failed bundle's partial folder is removed, cancellation included. + """ + deadline = asyncio.timeout(BUNDLE_RESOLVE_TIMEOUT_SECONDS) + try: + async with deadline: + return await source.materialize(ref, dest), None, False + except Exception as e: + timed_out = deadline.expired() + _remove_tree(dest) + return None, _failure_for(ref, e, timed_out=timed_out), timed_out + except BaseException: + _remove_tree(dest) + raise + + +async def _empty_set( + destination: Path | None, source: SkillBundleSource | None +) -> SkillSet: + """The set for an agent that binds no bundle: nothing fetched, no span, no temp folder. + + A caller ``destination`` is still reset, so last run's bundles and manifest + do not survive an agent that unbinds every bundle; a failed reset is logged + by ``_remove_tree``. A caller-supplied source is closed. + """ + if destination is not None: + _remove_tree(destination) + source_name = _source_name(source) + if source is not None: + await _close_source(source) + return SkillSet(source=source_name, bundles=[], failures=[]) + + +def _unexpected_failure(refs: list[SkillBundleRef], error: Exception) -> SkillSet: + """Every bundle reported ``source_unavailable`` after an error nothing else handled.""" + return SkillSet( + source=configured_source_kind(), + bundles=[], + failures=[ + BundleFailure( + ref=ref, + reason="source_unavailable", + detail=f"unexpected error: {error!r}", + ) + for ref in refs + ], + ) + + +def _source_name(source: SkillBundleSource | None) -> str: + """The telemetry name of ``source``, or of the configured one when none was given.""" + return source.name if source is not None else configured_source_kind() + + +async def _close_source(source: SkillBundleSource) -> None: + """Close ``source``; a failure is a warning, never a reason to fail the run.""" + try: + await source.aclose() + except Exception as e: + logger.warning("Closing the skills source %s failed: %r", source.name, e) + + +def _fail_all( + span: Span, + source_name: str, + refs: list[SkillBundleRef], + *, + reason: FailureReason, + detail: str, +) -> SkillSet: + """Report every bundle as skipped for one shared cause; nothing was materialized.""" + failures = [BundleFailure(ref=ref, reason=reason, detail=detail) for ref in refs] + for failure in failures: + record_bundle_failed(span, failure) + result = SkillSet(source=source_name, bundles=[], failures=failures) + record_bundles_resolved(span, result) + return result + + +def _failure_for( + ref: SkillBundleRef, error: Exception, *, timed_out: bool +) -> BundleFailure: + if timed_out: + return BundleFailure( + ref=ref, + reason="source_unavailable", + detail=f"timed out after {BUNDLE_RESOLVE_TIMEOUT_SECONDS}s", + ) + if isinstance(error, BundleSourceError): + return BundleFailure(ref=ref, reason=error.reason, detail=error.detail) + # A source bug must not take the run down. + return BundleFailure(ref=ref, reason="source_unavailable", detail=repr(error)) + + +def _remove_tree(path: Path) -> bool: + """Delete ``path``; ``False``, with a warning, when anything is left behind. + + A symlink is not followed: ``rmtree`` refuses it, so it stays and the + caller reports the folder as unusable instead of emptying its target. + """ + shutil.rmtree(path, ignore_errors=True) + # A dangling junction on Windows is neither a symlink nor existing here, so + # it counts as removed; the source's later mkdir then fails on it and is + # reported as source_unavailable. + if path.is_symlink() or path.exists(): + logger.warning("Could not remove %s", path) + return False + return True + + +def _warn_duplicate_skill_names(bundles: list[SkillBundle]) -> None: + """Name collisions are legal (the prompt qualifies names) but worth a line in the job log.""" + seen: dict[str, str] = {} + for bundle in bundles: + for skill in bundle.skills: + if skill.name in seen: + logger.warning( + "Skill '%s' exists in bundles '%s' and '%s'; both are available " + "to the model as '%s/%s' and '%s/%s'", + skill.name, + seen[skill.name], + bundle.ref.name, + seen[skill.name], + skill.name, + bundle.ref.name, + skill.name, + ) + seen[skill.name] = bundle.ref.name + + +def _run_sync(coro: Coroutine[Any, Any, T]) -> T: + """Run ``coro`` to completion from synchronous code. + + Uses ``asyncio.run`` when no event loop runs in this thread; otherwise runs it on a + worker thread with a copy of the current context, so the resource-overwrite ContextVar + (and the OpenTelemetry context) are visible to the coroutine. + + Args: + coro: The coroutine to run; it is always consumed. + + Returns: + The coroutine's result. + + Raises: + BaseException: Whatever the coroutine raised. + """ + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coro) + context = contextvars.copy_context() + + def run_in_context() -> T: + return context.run(asyncio.run, coro) + + with concurrent.futures.ThreadPoolExecutor( + max_workers=1, thread_name_prefix="uipath-skills" + ) as pool: + try: + future = pool.submit(run_in_context) + except RuntimeError: + # The interpreter is shutting down and no thread will run it. + coro.close() + raise + return future.result() + + +__all__ = ["SkillBundleLoadError", "SkillsService"] diff --git a/packages/uipath-platform/src/uipath/platform/skills/_sources.py b/packages/uipath-platform/src/uipath/platform/skills/_sources.py new file mode 100644 index 000000000..ad657b5c0 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/skills/_sources.py @@ -0,0 +1,385 @@ +"""Where skill bundles come from: a local folder, or the Orchestrator Skills API once it ships.""" + +from __future__ import annotations + +import asyncio +import logging +import os +import shutil +import threading +from pathlib import Path +from typing import Protocol + +from uipath.platform.common import UiPathApiConfig, UiPathExecutionContext + +from ._constants import ( + DEFAULT_SOURCE_KIND, + ENV_SKILLS_LOCAL_ROOT, + ENV_SKILLS_SOURCE, + MAX_BUNDLE_BYTES, + MAX_FILE_BYTES, + SKILL_FILE_NAME, + SKILLS_DIR_NAME, +) +from ._layout import find_skill_md, is_skill_md, iter_skill_files, read_bundle_version +from ._skill_md import parse_skill_description +from .skills import FailureReason, Skill, SkillBundle, SkillBundleRef, VersionMode + +logger = logging.getLogger(__name__) + + +class BundleSourceError(Exception): + """A bundle could not be materialized; ``reason`` is the ``BundleFailure.reason`` to report.""" + + def __init__( + self, detail: str, *, reason: FailureReason = "source_unavailable" + ) -> None: + """Describe the failure. + + Args: + detail: Human-readable context for logs and ``BundleFailure.detail``. + reason: Why the bundle is skipped; ``source_unavailable`` by default. + """ + super().__init__(detail) + self.detail = detail + self.reason: FailureReason = reason + + +class SkillBundleSource(Protocol): + """Fetches a bound skill bundle and writes its skills into a folder.""" + + name: str + """Short label for telemetry: ``local`` | ``orchestrator``.""" + + async def materialize(self, ref: SkillBundleRef, dest: Path) -> SkillBundle: + """Write the bundle's skills under ``dest//`` and describe what was written. + + ``dest`` is created even when the bundle has no usable skill. On an + error, what was written so far is left under ``dest`` for the caller to + remove. + + Args: + ref: The bound bundle; ``ref.lookup_name`` selects what to fetch. + dest: The bundle folder the caller owns. It must not exist yet or be + empty: the caller resets it before calling and removes it on + failure. A leftover ``dest/`` folder is taken for an + earlier skill of this bundle and shadows the real one. + + Returns: + The resolved version and the skills written under ``dest``. + + Raises: + BundleSourceError: The bundle cannot be used; its ``reason`` says + why (``not_found``, ``version_not_found``, ``invalid``, + ``forbidden``, ``too_large`` or ``source_unavailable``). + """ + ... + + async def aclose(self) -> None: + """Release what the source holds open, such as HTTP clients.""" + ... + + +class LocalFolderBundleSource: + """Bundles laid out as ``///SKILL.md``, with an optional ``bundle.json``. + + Meant for dev runs and Agent Builder debug copies: symlinked files are + followed and copied like regular files. The copy runs on a worker thread, + so it neither blocks the event loop nor outlives the caller's time budget + by more than one file. + """ + + name = "local" + + def __init__(self, root: Path) -> None: + """Serve bundles from ``root``. + + Args: + root: The folder holding one subfolder per bundle. + """ + self.root = root + + async def materialize(self, ref: SkillBundleRef, dest: Path) -> SkillBundle: + """Copy ``//`` skills into ``dest``. + + A pinned version that differs from the folder's is loaded anyway, with a + warning: local folders hold one version only. Hidden folders and folders + without a ``SKILL.md`` are not skills; the name is matched in any case + and copied as ``SKILL.md``. A skill whose ``SKILL.md`` is too + large or not UTF-8 is skipped with a warning. Inside a skill, hidden files + and files over ``MAX_FILE_BYTES`` are skipped. On an error, what was + written so far is left under ``dest`` for the caller to remove. + + When the caller is cancelled or its timeout fires, the copy stops before + its next file and this returns only once the worker thread has stopped, + so nothing is written into ``dest`` after the caller removes it. + + Args: + ref: The bound bundle. + dest: The bundle folder the caller owns. It must not exist yet or be + empty: the caller resets it before calling and removes it on + failure. It is created here, so a bundle without a usable skill + still has its folder. + + Returns: + The folder's version and the copied skills, sorted by folder name. + + Raises: + BundleSourceError: ``not_found`` if ``root`` has no folder for the + bundle, ``too_large`` if the copied files exceed + ``MAX_BUNDLE_BYTES``, ``source_unavailable`` if the bundle folder + cannot be read or ``dest`` cannot be written. + """ + stop = threading.Event() + copy = asyncio.ensure_future( + asyncio.to_thread(self._materialize, ref, dest, stop) + ) + try: + return await asyncio.shield(copy) + except BaseException: + stop.set() + # Shielded, so the thread is waited for even though this task was + # cancelled; it stops within one file. + await asyncio.wait([copy]) + raise + + def _materialize( + self, ref: SkillBundleRef, dest: Path, stop: threading.Event + ) -> SkillBundle: + """``materialize`` on the worker thread; ``stop`` ends it before the next file.""" + bundle_dir = self.root / ref.lookup_name + try: + if not bundle_dir.is_dir(): + raise BundleSourceError(f"no folder {bundle_dir}", reason="not_found") + version = read_bundle_version(bundle_dir) + if ref.version_mode is VersionMode.PINNED and ref.version != version: + logger.warning( + "Skill bundle '%s' is pinned to version %s but the local folder is %s; loading it anyway", + ref.name, + ref.version, + version, + ) + dest.mkdir(parents=True, exist_ok=True) + budget = _ByteBudget(ref.name) + skills: list[Skill] = [] + for skill_dir in sorted(bundle_dir.iterdir(), key=lambda p: p.name): + _check(stop) + skill_md_path = find_skill_md(skill_dir) + if skill_md_path is None: + continue + try: + skill_md = _read_skill_md(skill_md_path) + except _SkipSkill as e: + logger.warning( + "Skipping skill '%s' in bundle '%s': %s", + skill_dir.name, + ref.name, + e, + ) + continue + skills.append( + _copy_skill( + ref.name, + skill_dir, + skill_md_path.name, + skill_md, + dest / skill_dir.name, + budget, + stop, + ) + ) + except OSError as e: + raise BundleSourceError( + f"cannot read or copy bundle '{ref.name}': {e}" + ) from e + else: + return SkillBundle(ref=ref, version=version, skills=skills) + + async def aclose(self) -> None: + """Nothing to release.""" + + +class _SkipSkill(Exception): + """Leave one skill out of the bundle; the message says why.""" + + +class _Stopped(Exception): + """The caller gave up on the copy; nothing more is written.""" + + +def _check(stop: threading.Event) -> None: + if stop.is_set(): + raise _Stopped() + + +class _ByteBudget: + """Running total of bytes copied for one bundle, capped at ``MAX_BUNDLE_BYTES``.""" + + def __init__(self, bundle: str) -> None: + self.bundle = bundle + self.used = 0 + + def spend(self, size: int) -> None: + self.used += size + if self.used > MAX_BUNDLE_BYTES: + raise BundleSourceError( + f"skill bundle '{self.bundle}' is over {MAX_BUNDLE_BYTES} bytes", + reason="too_large", + ) + + def refund(self, size: int) -> None: + self.used -= size + + +def _read_skill_md(path: Path) -> str: + """The source ``SKILL.md`` text, checked before anything is copied. + + Raises: + _SkipSkill: If the file is too large or cannot be read as UTF-8. + """ + try: + if path.stat().st_size > MAX_FILE_BYTES: + raise _SkipSkill(f"unusable {SKILL_FILE_NAME}: over {MAX_FILE_BYTES} bytes") + return path.read_text(encoding="utf-8") + except (OSError, ValueError) as e: + raise _SkipSkill(f"unusable {SKILL_FILE_NAME}: {e}") from e + + +def _copy_skill( + bundle: str, + skill_dir: Path, + skill_md_name: str, + skill_md: str, + dest: Path, + budget: _ByteBudget, + stop: threading.Event, +) -> Skill: + """Copy a skill folder, skipping hidden and oversized files; ``SKILL.md`` is listed first. + + ``skill_md_name`` is the source spelling of ``SKILL.md`` (``skill.md``, for + instance); it is written as ``SKILL.md`` so readers find it by the usual + name, and any other spelling next to it is left out. + """ + files: list[str] = [] + for rel in iter_skill_files(skill_dir): + _check(stop) + if is_skill_md(rel) and rel != skill_md_name: + logger.warning( + "Skipping %s/%s in bundle '%s': the skill already has %s", + skill_dir.name, + rel, + bundle, + skill_md_name, + ) + continue + path = skill_dir / rel + size = path.stat().st_size + if size > MAX_FILE_BYTES: + logger.warning( + "Skipping %s/%s in bundle '%s': over %d bytes", + skill_dir.name, + rel, + bundle, + MAX_FILE_BYTES, + ) + continue + budget.spend(size) + written = SKILL_FILE_NAME if rel == skill_md_name else rel + target = dest / written + target.parent.mkdir(parents=True, exist_ok=True) + # Content only: copy2 would carry read-only bits into the bundle folder. + shutil.copyfile(path, target) + files.append(written) + return Skill( + name=skill_dir.name, + description=parse_skill_description(skill_md), + files=files, + ) + + +# --- Orchestrator --- + + +class OrchestratorBundleSource: + """Bundles from the Orchestrator Skills API. A placeholder until the API ships. + + Every bundle is reported as ``source_unavailable``, so selecting + ``UIPATH_SKILLS_SOURCE=orchestrator`` today runs the agent without skills. + """ + + name = "orchestrator" + + def __init__( + self, config: UiPathApiConfig | None, execution_context: UiPathExecutionContext + ) -> None: + """Keep what the Skills API calls will need. + + Args: + config: Base URL and access token, or ``None`` when the caller has none. + execution_context: The job's execution context, for the default folder. + """ + self.config = config + self.execution_context = execution_context + + async def materialize(self, ref: SkillBundleRef, dest: Path) -> SkillBundle: + """Report the bundle as unavailable until the Skills API ships. + + Once it ships: resolve ``ref.lookup_name`` in ``ref.folder_path`` at the + pinned or latest version, write ``dest//...`` within the same + limits as ``LocalFolderBundleSource``, and raise ``BundleSourceError`` + with the matching ``reason`` for API errors. + + Raises: + BundleSourceError: Always, ``source_unavailable``. + """ + raise BundleSourceError("the Orchestrator Skills API is not available yet") + + async def aclose(self) -> None: + """Nothing to release yet.""" + + +# --- Source selection --- + + +def configured_source_kind() -> str: + """``UIPATH_SKILLS_SOURCE``, normalized; ``DEFAULT_SOURCE_KIND`` when unset or blank. + + Returns: + The lower-cased value, which may be one ``get_bundle_source`` does not know. + """ + return ( + os.environ.get(ENV_SKILLS_SOURCE) or "" + ).strip().lower() or DEFAULT_SOURCE_KIND + + +def get_bundle_source( + config: UiPathApiConfig | None, context: UiPathExecutionContext | None +) -> SkillBundleSource: + """The bundle source named by ``UIPATH_SKILLS_SOURCE``. + + ``local`` (the default) reads ``UIPATH_SKILLS_LOCAL_ROOT``, else + ``./skills``. ``orchestrator`` is the Skills API placeholder, which skips + every bundle until the API ships. An unknown value falls back to ``local`` + with a warning. + + Args: + config: The SDK configuration the Orchestrator source will call the + Skills API with; ``None`` when the caller has none. + context: The job's execution context; a fresh one is read from the + environment when ``None``. + + Returns: + A source the caller closes with ``aclose`` when done. + """ + kind = configured_source_kind() + if kind == "orchestrator": + return OrchestratorBundleSource(config, context or UiPathExecutionContext()) + if kind != "local": + logger.warning( + "Unknown %s=%r; loading skill bundles from the local folder", + ENV_SKILLS_SOURCE, + kind, + ) + return LocalFolderBundleSource( + Path(os.environ.get(ENV_SKILLS_LOCAL_ROOT) or Path.cwd() / SKILLS_DIR_NAME) + ) diff --git a/packages/uipath-platform/src/uipath/platform/skills/_telemetry.py b/packages/uipath-platform/src/uipath/platform/skills/_telemetry.py new file mode 100644 index 000000000..896852cf0 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/skills/_telemetry.py @@ -0,0 +1,243 @@ +"""Telemetry for skill bundles (contract section 9 names, DEC-31). + +Both spans carry the LLMOps markers (``type``/``span_type`` and +``uipath.custom_instrumentation``) so the LLMOps exporters keep them. Until the +Agents run UI knows the ``skillBundlesResolved`` and ``skillLoad`` types it may +render them as generic custom spans. + +Raw OpenTelemetry spans are used instead of ``@traced``: the resolve span +has to be yielded to the caller so bundle events can be attached to it, and +the three LLMOps marker attributes are a cross-runtime contract that the +decorator does not set. +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Iterator +from contextlib import contextmanager +from typing import TYPE_CHECKING + +from opentelemetry import trace +from opentelemetry.trace import Status, StatusCode + +if TYPE_CHECKING: + from .skills import ( + BundleFailure, + InvokedBy, + Skill, + SkillBundle, + SkillBundleRef, + SkillSet, + ) + +logger = logging.getLogger(__name__) +_tracer = trace.get_tracer("uipath.platform.skills") + +# Same values as SpanType.SKILL_BUNDLES_RESOLVED / SpanType.SKILL_LOAD in the +# LLMOps span catalog (uipath-agents-python). Kept as literals: this package +# does not depend on that catalog. +SPAN_TYPE_BUNDLES_RESOLVED = "skillBundlesResolved" +SPAN_TYPE_SKILL_LOAD = "skillLoad" + + +@contextmanager +def bundles_resolved_span() -> Iterator[trace.Span]: + """The one span that wraps resolving every bound bundle of a run. + + Yields: + The current span; pass it to ``record_bundle_failed`` and + ``record_bundles_resolved``. + """ + with _tracer.start_as_current_span( + "Skill bundles resolved", + attributes=_llmops_attributes(SPAN_TYPE_BUNDLES_RESOLVED), + ) as span: + yield span + + +def record_bundles_resolved(span: trace.Span, result: SkillSet) -> None: + """Describe on ``span`` what was resolved and what failed. + + Args: + span: The span from ``bundles_resolved_span``. + result: The resolved set, including the failures. + """ + span.set_attribute("skill.source", result.source) + span.set_attribute("skill.bundle.count", len(result.bundles)) + span.set_attribute("skill.count", sum(len(b.skills) for b in result.bundles)) + # OTel attributes only take primitives, so the failure list is JSON. + span.set_attribute( + "skill.failures", + json.dumps( + [ + { + "bundle": f.ref.name, + "lookup_name": f.ref.lookup_name, + "folder": f.ref.folder_path or "", + "version": f.ref.version or "", + "reason": f.reason, + } + for f in result.failures + ] + ), + ) + for bundle in result.bundles: + span.add_event( + "skill.bundle.resolved", + { + **_ref_attributes(bundle.ref, bundle.version), + "skill.bundle.skill_count": len(bundle.skills), + }, + ) + logger.info( + "Resolved %d skill bundle(s) from %s: %s", + len(result.bundles), + result.source, + ", ".join( + f"{b.ref.name}@{b.version} ({len(b.skills)} skills)" for b in result.bundles + ) + or "none", + ) + + +def record_bundle_failed(span: trace.Span, failure: BundleFailure) -> None: + """An event on ``span`` plus a warning; the run continues without the bundle. + + Args: + span: The span from ``bundles_resolved_span``. + failure: The bundle that was skipped and why. + """ + span.add_event( + "skill.bundle.failed", + { + **_ref_attributes(failure.ref, failure.ref.version or ""), + "skill.reason": failure.reason, + "skill.detail": failure.detail, + }, + ) + logger.warning( + "Skill bundle '%s' skipped (%s): %s", + failure.ref.name, + failure.reason, + failure.detail, + ) + + +class SkillLoad: + """An open ``Skill load`` span, yielded by ``skill_load_span``. + + Call ``loaded`` once the ``SKILL.md`` text is in hand, or ``fail`` when the + read did not produce it. A span closed with neither is marked as an error. + """ + + def __init__(self, span: trace.Span, bundle: SkillBundle, skill: Skill) -> None: + """Wrap ``span``, the current span for the load of ``skill`` from ``bundle``.""" + self.span = span + self._bundle = bundle + self._skill = skill + self._settled = False + + def loaded(self, size: int) -> None: + """Record a successful load. + + Args: + size: UTF-8 bytes of the text handed to the reader, recorded as + ``skill.bytes``. + """ + self._settled = True + self.span.set_attribute("skill.bytes", size) + logger.info( + "Skill loaded: %s/%s (v%s)", + self._bundle.ref.name, + self._skill.name, + self._bundle.version, + ) + + def fail(self, reason: str) -> None: + """Mark the span as an error: the read ran but returned no skill text. + + Args: + reason: Why, recorded as the span status description. + """ + self._settled = True + self.span.set_status(Status(StatusCode.ERROR, reason)) + + +@contextmanager +def skill_load_span( + bundle: SkillBundle, skill: Skill, *, invoked_by: InvokedBy = "model" +) -> Iterator[SkillLoad]: + """The ``Skill load`` span (DEC-31), current for the duration of the read. + + Spans started inside, such as the tool call that reads the ``SKILL.md``, + become its children. An exception from the body is recorded on the span + and propagates. + + Args: + bundle: The bundle the skill belongs to. + skill: The skill whose ``SKILL.md`` is being read. + invoked_by: Who asked for the skill, recorded as ``skill.invoked_by``: + ``"model"`` for a tool call, ``"user"`` when the agent's own code + loaded it. + + Yields: + The open load; call ``loaded`` or ``fail`` on it. + """ + with _tracer.start_as_current_span( + f"Skill load - {bundle.ref.name}/{skill.name}", + attributes={ + **_llmops_attributes(SPAN_TYPE_SKILL_LOAD), + **_ref_attributes(bundle.ref, bundle.version), + "skill.name": skill.name, + "skill.invoked_by": invoked_by, + }, + ) as span: + load = SkillLoad(span, bundle, skill) + yield load + if not load._settled: + load.fail("The skill was not loaded") + + +def record_skill_loaded( + bundle: SkillBundle, + skill: Skill, + *, + size: int, + invoked_by: InvokedBy = "model", +) -> None: + """One point-in-time span for a skill that was already read (DEC-31). + + Args: + bundle: The bundle the skill belongs to. + skill: The skill whose ``SKILL.md`` was read. + size: UTF-8 bytes of the text handed to the reader, recorded as + ``skill.bytes``. + invoked_by: Who asked for the skill, recorded as ``skill.invoked_by``: + ``"model"`` for a tool call, ``"user"`` when the agent's own code + loaded it. + """ + with skill_load_span(bundle, skill, invoked_by=invoked_by) as load: + load.loaded(size) + + +def _llmops_attributes(span_type: str) -> dict[str, str | bool]: + # Mirrors BaseSpanAttributes.to_otel_attributes; LLMOps exporters drop + # spans without uipath.custom_instrumentation. + return { + "type": span_type, + "span_type": span_type, + "uipath.custom_instrumentation": True, + } + + +def _ref_attributes(ref: SkillBundleRef, version: str) -> dict[str, str]: + """The bundle identity shared by every skill span and event.""" + return { + "skill.bundle.name": ref.name, + "skill.bundle.lookup_name": ref.lookup_name, + "skill.folder": ref.folder_path or "", + "skill.version": version, + "skill.version_mode": ref.version_mode.value, + } diff --git a/packages/uipath-platform/src/uipath/platform/skills/skills.py b/packages/uipath-platform/src/uipath/platform/skills/skills.py new file mode 100644 index 000000000..efdc03b42 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/skills/skills.py @@ -0,0 +1,354 @@ +"""Skill bundle references, resolved bundles and the skills manifest.""" + +from __future__ import annotations + +import json +import logging +import shutil +from enum import StrEnum +from pathlib import Path, PurePosixPath +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from ._constants import ( + MANIFEST_FILE_NAME, + OVERWRITE_TYPE, + SKILL_FILE_NAME, + SKILLS_DIR_NAME, +) +from ._layout import is_plain_name +from ._telemetry import record_skill_loaded + +logger = logging.getLogger(__name__) + + +class VersionMode(StrEnum): + """How a bundle version is chosen. + + ``pinned`` uses a fixed version; ``latest`` takes the newest one at run start. + """ + + # Keep in sync with the Literal on common._bindings.SkillBundleResourceOverwrite. + PINNED = "pinned" + LATEST = "latest" + + +class SkillBundleRef(BaseModel): + """One bound skill bundle: what the agent asks the Skills API for.""" + + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + name: str + folder_path: str | None = Field(default=None, alias="folderPath") + version_mode: VersionMode = Field(default=VersionMode.LATEST, alias="versionMode") + version: str | None = None + source_name: str | None = Field(default=None, alias="sourceName") + """The deploy-time bundle name, when a binding points ``name`` elsewhere.""" + + @field_validator("name", "source_name") + @classmethod + def _name_is_one_folder(cls, name: str | None) -> str | None: + # The name becomes a folder under the set's root that is later removed + # with rmtree; hidden names are refused so a bundle can't shadow the + # manifest. + if name is None: + return None + if not is_plain_name(name) or name != name.strip(): + raise ValueError("a skill bundle name must be a single folder name") + return name + + @model_validator(mode="after") + def _version_matches_mode(self) -> SkillBundleRef: + if self.version_mode is VersionMode.LATEST: + self.version = None + elif not self.version: + raise ValueError("a pinned skill bundle needs a version") + return self + + @property + def key(self) -> str: + """The ``resourceOverwrites`` key, ``skillBundle.``.""" + return f"{OVERWRITE_TYPE}.{self.name}" + + @property + def lookup_name(self) -> str: + """The bundle to fetch from Orchestrator or the local folder. + + ``name`` stays the bundle's identity for the agent (bundle folder, + prompt label, manifest); a deploy-time binding only changes this. + """ + return self.source_name or self.name + + +class Skill(BaseModel): + """One skill a source wrote into its bundle folder. + + ``name`` equals the skill's folder name inside the bundle. ``name`` and every + entry of ``files`` are checked to stay inside that folder; ``SkillSet.read`` + relies on it. + """ + + name: str + description: str + files: list[str] = Field(default_factory=list) + """Paths relative to the skill folder in POSIX form, ``SKILL.md`` first.""" + + @field_validator("name") + @classmethod + def _name_is_one_folder(cls, name: str) -> str: + if not is_plain_name(name): + raise ValueError("a skill name must be a single folder name") + return name + + @field_validator("files") + @classmethod + def _files_stay_inside_the_folder(cls, files: list[str]) -> list[str]: + for f in files: + if not f or not all(is_plain_name(part) for part in f.split("/")): + raise ValueError(f"skill file path escapes the skill folder: {f!r}") + return files + + +class SkillBundle(BaseModel): + """A bundle a source resolved to a concrete version and wrote to disk.""" + + ref: SkillBundleRef + version: str + skills: list[Skill] + + @property + def virtual_root(self) -> str: + """The bundle folder as the agent sees it, ``/skills//``.""" + return f"/{SKILLS_DIR_NAME}/{self.ref.name}/" + + @property + def label(self) -> str: + """Display name with the resolved version, `` v``.""" + return f"{self.ref.name} v{self.version}" + + +FailureReason = Literal[ + "not_found", + "version_not_found", + "invalid", + "too_large", + "source_unavailable", + "forbidden", +] +"""Why a bundle was skipped. + +``invalid`` also covers stale files that could not be removed before materializing. +""" + +InvokedBy = Literal["model", "user"] +"""Who asked for a skill: ``model`` for a tool call, ``user`` for the agent's own code.""" + + +class BundleFailure(BaseModel): + """A bound bundle that could not be materialized, and why.""" + + ref: SkillBundleRef + reason: FailureReason + detail: str = "" + + +class SkillSet(BaseModel): + """The skill bundles resolved for one run, and the bundles that could not be. + + ``save``/``load`` keep the set as ``.uipath-skills-manifest.json`` inside the + folder the bundles were written to; ``root`` and ``owns_root`` never land in + that manifest. + """ + + source: str + """Where the bundles came from, for instance ``local`` or ``orchestrator``.""" + bundles: list[SkillBundle] + failures: list[BundleFailure] = Field(default_factory=list) + root: Path | None = Field(default=None, exclude=True) + """Folder the bundles were written to; ``None`` before materializing and after ``cleanup``.""" + owns_root: bool = Field(default=False, exclude=True, repr=False) + """Whether ``cleanup`` may remove ``root``: only for a temp folder this set created.""" + + def names(self) -> list[str]: + """Qualified ``/`` names in bundle order (D20).""" + return [f"{b.ref.name}/{s.name}" for b in self.bundles for s in b.skills] + + def find(self, qualified_name: str) -> tuple[SkillBundle, Skill] | None: + """The bundle and skill a qualified ``/`` name points to. + + Args: + qualified_name: A name as ``names`` returns it. + + Returns: + The matching bundle and skill, or ``None`` for an unknown name. + """ + bundle_name, _, skill_name = qualified_name.partition("/") + for bundle in self.bundles: + if bundle.ref.name != bundle_name: + continue + for skill in bundle.skills: + if skill.name == skill_name: + return bundle, skill + return None + + def prompt_section(self) -> str: + """``## Skills`` markdown listing every skill with its description, or ``""`` when no bundle has a skill.""" + if not any(bundle.skills for bundle in self.bundles): + return "" + lines = [ + "## Skills", + "", + "Skills are packaged instructions bound to this agent, grouped in bundles. " + "Each skill is named `/`. Load a skill's full instructions " + "only when the task matches its description, then follow them step by " + "step. Scripts inside a skill cannot be executed here; a skill may name " + "tools you do not have — continue with the tools you have and say what " + "was missing.", + "", + ] + for bundle in self.bundles: + for skill in bundle.skills: + description = " ".join(skill.description.split()) + lines.append( + f"- **{bundle.ref.name}/{skill.name}** (bundle v{bundle.version}): " + f"{description}" + ) + return "\n".join(lines) + + def read( + self, + qualified_name: str, + path: str = SKILL_FILE_NAME, + *, + invoked_by: InvokedBy = "model", + ) -> str: + """A skill file's text. Reading ``SKILL.md`` records a skill-load span. + + Args: + qualified_name: The ``/`` name. + path: A file the skill lists, in the POSIX form ``Skill.files`` uses, + relative to the skill folder. + invoked_by: Who asked for the file, for the span: ``"model"`` for a + tool call, ``"user"`` when the agent's own code loaded it. + + Returns: + The file text decoded as UTF-8, a BOM dropped. When the file is not + valid UTF-8 a warning is logged and undecodable bytes are replaced. + + Raises: + KeyError: Unknown skill, or a file the skill does not list. + RuntimeError: The set has no ``root``: manifest only, or cleaned up. + """ + hit = self.find(qualified_name) + if hit is None: + raise KeyError(qualified_name) + bundle, skill = hit + if path not in skill.files: + raise KeyError(path) + if self.root is None: + raise RuntimeError( + "this SkillSet was not materialized to disk, or was cleaned up" + ) + file = self.root / bundle.ref.name / skill.name / path + try: + text = file.read_text(encoding="utf-8-sig") + except UnicodeDecodeError as e: + logger.warning( + "Skill file %s is not valid UTF-8 (%s); replacing undecodable bytes", + file, + e, + ) + text = file.read_text(encoding="utf-8-sig", errors="replace") + if path == SKILL_FILE_NAME: + record_skill_loaded( + bundle, skill, size=len(text.encode("utf-8")), invoked_by=invoked_by + ) + return text + + def cleanup(self) -> None: + """Remove the temp root this set created; a caller-supplied destination is left alone. + + Afterwards the set has no ``root``, so ``read`` raises ``RuntimeError`` + and a second ``cleanup`` does nothing. A root that cannot be removed is + logged as a warning. + """ + if self.owns_root and self.root is not None: + try: + shutil.rmtree(self.root) + except FileNotFoundError: + pass + except OSError as e: + logger.warning("Could not remove skills folder %s: %s", self.root, e) + self.owns_root = False + self.root = None + + def skill_at(self, virtual_path: str) -> tuple[SkillBundle, Skill] | None: + """The bundle and skill whose ``SKILL.md`` sits at ``virtual_path``. + + Args: + virtual_path: A path in the agent's virtual filesystem; a relative + path is read from the root, as a virtual filesystem backend does. + + Returns: + The matching bundle and skill, or ``None`` when the path is not a + ``SKILL.md`` of a materialized skill. + """ + if not virtual_path.startswith("/"): + virtual_path = f"/{virtual_path}" + # ("/", "skills", , , "SKILL.md") + parts = PurePosixPath(virtual_path).parts + if ( + len(parts) != 5 + or parts[1] != SKILLS_DIR_NAME + or parts[4] != SKILL_FILE_NAME + ): + return None + for bundle in self.bundles: + if bundle.ref.name != parts[2]: + continue + for skill in bundle.skills: + if skill.name == parts[3]: + return bundle, skill + return None + + def save(self, skills_dir: Path) -> None: + """Write the manifest into ``skills_dir``, creating the folder if needed. + + Args: + skills_dir: The folder the bundles were written to. + """ + skills_dir.mkdir(parents=True, exist_ok=True) + with (skills_dir / MANIFEST_FILE_NAME).open( + "w", encoding="utf-8", newline="\n" + ) as manifest: + manifest.write(self.model_dump_json(by_alias=True, indent=2)) + + @classmethod + def load(cls, skills_dir: Path) -> SkillSet | None: + """Read the manifest from ``skills_dir``. + + The set comes back with ``root=skills_dir`` and ``owns_root=False``, so + ``read`` works right away and ``cleanup`` leaves the folder alone. A + ``root`` or ``owns_root`` key inside the file is ignored; ``save`` never + writes them. + + Args: + skills_dir: The folder the bundles were written to. + + Returns: + The manifest, or ``None`` when the folder holds none. + + Raises: + ValueError: If the manifest is not valid JSON (``json.JSONDecodeError``) + or does not match the model (``pydantic.ValidationError``). + """ + path = skills_dir / MANIFEST_FILE_NAME + if not path.is_file(): + return None + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"{path} does not hold a JSON object") + data.pop("root", None) + data.pop("owns_root", None) + return cls.model_validate({**data, "root": skills_dir, "owns_root": False}) diff --git a/packages/uipath-platform/tests/common/test_bindings_skill_bundle.py b/packages/uipath-platform/tests/common/test_bindings_skill_bundle.py new file mode 100644 index 000000000..ff196cbea --- /dev/null +++ b/packages/uipath-platform/tests/common/test_bindings_skill_bundle.py @@ -0,0 +1,110 @@ +"""Tests for the ``skillBundle`` resource overwrite type and its parsing.""" + +from typing import Any, get_args + +import pytest +from pydantic import ValidationError + +from uipath.platform.common import ( + ConnectionResourceOverwrite, + EntityResourceOverwrite, + GenericResourceOverwrite, + ResourceOverwriteParser, + SkillBundleResourceOverwrite, +) +from uipath.platform.skills import VersionMode + + +def _parse(key: str, entry: dict[str, Any]) -> SkillBundleResourceOverwrite: + ow = ResourceOverwriteParser.parse(key, entry) + assert isinstance(ow, SkillBundleResourceOverwrite) + return ow + + +def test_a_full_entry_parses_with_its_identifiers() -> None: + ow = _parse( + "skillBundle.finance", + { + "name": "finance-eu", + "folderPath": "Prod/Skills", + "versionMode": "pinned", + "version": "7", + "isEnabled": True, + }, + ) + assert (ow.name, ow.folder_path, ow.version_mode, ow.version) == ( + "finance-eu", + "Prod/Skills", + "pinned", + "7", + ) + assert (ow.resource_identifier, ow.folder_identifier) == ( + "finance-eu", + "Prod/Skills", + ) + assert "isEnabled" not in ow.model_dump(by_alias=True) + + +@pytest.mark.parametrize("version", [None, "4", ""]) +def test_latest_carries_no_version(version: str | None) -> None: + entry = {"name": "finance", "folderPath": "F", "versionMode": "latest"} + ow = _parse("skillBundle.finance", {**entry, "version": version}) + assert (ow.version_mode, ow.version) == ("latest", None) + + +_FULL = {"name": "finance", "folderPath": "F", "versionMode": "pinned", "version": "7"} + + +@pytest.mark.parametrize( + "missing_or_blank", + [ + {"name": None}, + {"folderPath": None}, + {"versionMode": None}, + {"version": None}, + {"name": ""}, + {"folderPath": " "}, + {"versionMode": ""}, + {"version": " "}, + {"versionMode": "cached"}, + ], + ids=[ + "no-name", + "no-folder", + "no-version-mode", + "pinned-without-version", + "blank-name", + "blank-folder", + "blank-version-mode", + "pinned-with-blank-version", + "unknown-version-mode", + ], +) +def test_an_incomplete_entry_is_rejected(missing_or_blank: dict[str, Any]) -> None: + entry = {**_FULL, **missing_or_blank} + entry = {k: v for k, v in entry.items() if v is not None} + with pytest.raises(ValidationError): + ResourceOverwriteParser.parse("skillBundle.finance", entry) + + +def test_the_version_mode_literal_matches_skills_version_mode() -> None: + annotation = SkillBundleResourceOverwrite.model_fields["version_mode"].annotation + assert set(get_args(annotation)) == {mode.value for mode in VersionMode} + + +@pytest.mark.parametrize( + ("key", "entry", "kind"), + [ + ("asset.A", {"name": "B", "folderPath": "F"}, GenericResourceOverwrite), + ("entity.E", {"name": "E2", "folderPath": "F"}, EntityResourceOverwrite), + ( + "connection.C", + {"connectionId": "c-2", "folderKey": "fk"}, + ConnectionResourceOverwrite, + ), + ], +) +def test_other_kinds_still_parse_to_their_own_type( + key: str, entry: dict[str, Any], kind: type +) -> None: + assert isinstance(ResourceOverwriteParser.parse(key, entry), kind) diff --git a/packages/uipath-platform/tests/services/skills/conftest.py b/packages/uipath-platform/tests/services/skills/conftest.py new file mode 100644 index 000000000..293c3805d --- /dev/null +++ b/packages/uipath-platform/tests/services/skills/conftest.py @@ -0,0 +1,33 @@ +"""Fixtures shared by the skills tests.""" + +import shutil +from pathlib import Path + +import pytest +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + +FIXTURES = Path(__file__).parents[1] / "tests_data" / "skills" +"""``tests/services/tests_data/skills``: bundles ``finance`` (v3) and ``ops`` (v1), both with ``vendor-lookup``.""" + + +@pytest.fixture +def root(tmp_path: Path) -> Path: + """A writable copy of the fixture bundles at ``tmp_path/skills``.""" + shutil.copytree(FIXTURES, tmp_path / "skills") + return tmp_path / "skills" + + +@pytest.fixture +def exporter(monkeypatch: pytest.MonkeyPatch) -> InMemorySpanExporter: + """Capture the spans the skills package emits.""" + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + monkeypatch.setattr( + "uipath.platform.skills._telemetry._tracer", provider.get_tracer("test") + ) + return exporter diff --git a/packages/uipath-platform/tests/services/skills/test_get_bundle_source.py b/packages/uipath-platform/tests/services/skills/test_get_bundle_source.py new file mode 100644 index 000000000..ef9032612 --- /dev/null +++ b/packages/uipath-platform/tests/services/skills/test_get_bundle_source.py @@ -0,0 +1,84 @@ +"""Tests for picking the skill bundle source from ``UIPATH_SKILLS_SOURCE``.""" + +import logging +from pathlib import Path + +import pytest + +from uipath.platform.common import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.skills._sources import ( + BundleSourceError, + LocalFolderBundleSource, + OrchestratorBundleSource, + get_bundle_source, +) +from uipath.platform.skills.skills import SkillBundleRef + + +@pytest.fixture(autouse=True) +def env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> pytest.MonkeyPatch: + monkeypatch.delenv("UIPATH_SKILLS_SOURCE", raising=False) + monkeypatch.delenv("UIPATH_SKILLS_LOCAL_ROOT", raising=False) + monkeypatch.chdir(tmp_path) + return monkeypatch + + +@pytest.mark.parametrize("value", [None, "", " ", " Local "]) +def test_local_is_the_default_and_reads_skills_under_cwd( + env: pytest.MonkeyPatch, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + value: str | None, +) -> None: + if value is not None: + env.setenv("UIPATH_SKILLS_SOURCE", value) + with caplog.at_level(logging.WARNING): + source = get_bundle_source(None, None) + assert isinstance(source, LocalFolderBundleSource) + assert source.root.resolve() == (tmp_path / "skills").resolve() + assert not caplog.records + + +def test_the_local_root_can_be_set(env: pytest.MonkeyPatch, tmp_path: Path) -> None: + env.setenv("UIPATH_SKILLS_LOCAL_ROOT", str(tmp_path / "elsewhere")) + source = get_bundle_source(None, None) + assert isinstance(source, LocalFolderBundleSource) + assert source.root == tmp_path / "elsewhere" + + +@pytest.mark.parametrize("value", ["s3", "mock"]) +def test_an_unknown_value_falls_back_to_local_with_a_warning( + env: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, value: str +) -> None: + env.setenv("UIPATH_SKILLS_SOURCE", value) + with caplog.at_level(logging.WARNING): + source = get_bundle_source(None, None) + assert isinstance(source, LocalFolderBundleSource) + assert repr(value) in caplog.text + + +@pytest.mark.parametrize("with_config", [True, False]) +async def test_orchestrator_is_a_placeholder_that_skips_every_bundle( + env: pytest.MonkeyPatch, + tmp_path: Path, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + with_config: bool, +) -> None: + env.setenv("UIPATH_SKILLS_SOURCE", " Orchestrator ") + if with_config: + source = get_bundle_source(config, execution_context) + else: + source = get_bundle_source(None, None) + assert isinstance(source, OrchestratorBundleSource) + assert source.name == "orchestrator" + if with_config: + assert (source.config, source.execution_context) == (config, execution_context) + else: + assert source.config is None + assert isinstance(source.execution_context, UiPathExecutionContext) + ref = SkillBundleRef(name="finance") + with pytest.raises(BundleSourceError, match="not available yet") as info: + await source.materialize(ref, tmp_path / "d") + assert info.value.reason == "source_unavailable" + assert not (tmp_path / "d").exists() diff --git a/packages/uipath-platform/tests/services/skills/test_layout.py b/packages/uipath-platform/tests/services/skills/test_layout.py new file mode 100644 index 000000000..5ecbb4c81 --- /dev/null +++ b/packages/uipath-platform/tests/services/skills/test_layout.py @@ -0,0 +1,104 @@ +"""Tests for the on-disk bundle layout helpers.""" + +import logging +from pathlib import Path + +import pytest + +from uipath.platform.skills._layout import ( + find_skill_md, + is_plain_name, + iter_skill_files, + read_bundle_version, +) + + +@pytest.mark.parametrize( + ("content", "expected", "warns"), + [ + (b'{"version": "3"}', "3", False), + (b'{"version": 7}', "7", False), + (b'{"version": null}', "local", False), + (b'{"version": ""}', "local", False), + (b"{}", "local", False), + (None, "local", False), + (b"{not json", "local", True), + (b"[1, 2]", "local", True), + (b'{"version": "\xff"}', "local", True), + ], +) +def test_read_bundle_version( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + content: bytes | None, + expected: str, + warns: bool, +) -> None: + if content is not None: + (tmp_path / "bundle.json").write_bytes(content) + with caplog.at_level(logging.WARNING): + assert read_bundle_version(tmp_path) == expected + assert ("bundle.json" in caplog.text) is warns + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("invoice-triage", True), + ("my skill", True), + ("v1.2", True), + ("", False), + (".", False), + ("..", False), + (".hidden", False), + ("a/b", False), + ("a" + chr(92) + "b", False), + ("C:", False), + ("a" + chr(0) + "b", False), + ], +) +def test_is_plain_name(name: str, expected: bool) -> None: + assert is_plain_name(name) is expected + + +def _is_case_sensitive(folder: Path) -> bool: + probe = folder / "case-probe" + probe.write_text("x", encoding="utf-8") + try: + return not (folder / "CASE-PROBE").exists() + finally: + probe.unlink() + + +def test_find_skill_md_matches_any_case(tmp_path: Path) -> None: + for rel in ["ok/SKILL.md", "lower/skill.md", "mixed/Skill.MD", ".hidden/SKILL.md"]: + (tmp_path / rel).parent.mkdir() + (tmp_path / rel).write_text("x", encoding="utf-8") + (tmp_path / "empty").mkdir() + (tmp_path / "nested" / "SKILL.md").mkdir(parents=True) + (tmp_path / "file.md").write_text("x", encoding="utf-8") + found = {p.name: find_skill_md(p) for p in tmp_path.iterdir()} + assert {k: v.name for k, v in found.items() if v is not None} == { + "ok": "SKILL.md", + "lower": "skill.md", + "mixed": "Skill.MD", + } + + +def test_find_skill_md_prefers_the_exact_name(tmp_path: Path) -> None: + if not _is_case_sensitive(tmp_path): + pytest.skip("the file system cannot hold two spellings of SKILL.md") + for name in ["skill.md", "SKILL.md", "Skill.md"]: + (tmp_path / name).write_text("x", encoding="utf-8") + found = find_skill_md(tmp_path) + assert found is not None and found.name == "SKILL.md" + + +@pytest.mark.parametrize("skill_md", ["SKILL.md", "skill.md"]) +def test_iter_skill_files_puts_skill_md_first_and_skips_hidden( + tmp_path: Path, skill_md: str +) -> None: + for rel in [skill_md, "a.md", "ref/b.md", ".secret", ".git/config", "ref/.c"]: + (tmp_path / rel).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / rel).write_text("x", encoding="utf-8") + assert iter_skill_files(tmp_path) == [skill_md, "a.md", "ref/b.md"] diff --git a/packages/uipath-platform/tests/services/skills/test_models.py b/packages/uipath-platform/tests/services/skills/test_models.py new file mode 100644 index 000000000..5f7f9fcba --- /dev/null +++ b/packages/uipath-platform/tests/services/skills/test_models.py @@ -0,0 +1,338 @@ +"""Tests for skill bundle models and the skills manifest.""" + +import json +import logging +import shutil +from pathlib import Path +from typing import Any + +import pytest +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from pydantic import ValidationError + +from uipath.platform.skills import ( + BundleFailure, + InvokedBy, + Skill, + SkillBundle, + SkillBundleRef, + SkillSet, + VersionMode, +) +from uipath.platform.skills._constants import MANIFEST_FILE_NAME + +BACKSLASH = chr(92) +NUL = chr(0) +UNSAFE_NAMES = ["", ".", "..", "a/b", "a" + BACKSLASH + "b", "c:", ".hidden"] +UNSAFE_NAMES += ["a" + NUL + "b"] + + +def _fixture_set(root: Path | None, *, owns_root: bool = False) -> SkillSet: + """The two fixture bundles as a set; ``root`` is where they were copied to.""" + finance = SkillBundle( + ref=SkillBundleRef( + name="finance", version_mode=VersionMode.PINNED, version="3" + ), + version="3", + skills=[ + Skill( + name="invoice-triage", + description="Triage invoices", + files=["SKILL.md", "reference/vendors.md"], + ), + Skill(name="vendor-lookup", description="Look up\n a vendor "), + ], + ) + ops = SkillBundle( + ref=SkillBundleRef(name="ops"), + version="1", + skills=[ + Skill( + name="vendor-lookup", + description="Ops vendor lookup", + files=["SKILL.md"], + ) + ], + ) + return SkillSet( + source="local", bundles=[finance, ops], root=root, owns_root=owns_root + ) + + +# --- SkillBundleRef --- + + +def test_a_ref_parses_camel_case_and_defaults_to_latest() -> None: + ref = SkillBundleRef.model_validate( + {"name": "finance", "folderPath": "Shared/Skills", "version": "3"} + ) + assert (ref.folder_path, ref.version_mode, ref.version) == ( + "Shared/Skills", + VersionMode.LATEST, + None, + ) + assert (ref.key, ref.lookup_name) == ("skillBundle.finance", "finance") + + +@pytest.mark.parametrize( + "values", + [ + {"name": "finance", "versionMode": "pinned"}, + *({"name": n} for n in [*UNSAFE_NAMES, " finance", MANIFEST_FILE_NAME]), + {"name": "finance", "sourceName": "a/b"}, + ], +) +def test_an_invalid_ref_is_rejected(values: dict[str, Any]) -> None: + with pytest.raises(ValidationError): + SkillBundleRef.model_validate(values) + + +# --- Skill and SkillBundle --- + + +@pytest.mark.parametrize("name", UNSAFE_NAMES) +def test_a_skill_name_must_be_one_folder(name: str) -> None: + with pytest.raises(ValidationError, match="single folder name"): + Skill(name=name, description="d") + + +@pytest.mark.parametrize( + "path", + [ + "", + "../../../secret.txt", + "reference/../SKILL.md", + "reference" + BACKSLASH + "vendors.md", + ".hidden/notes.md", + "/etc/passwd", + "reference//vendors.md", + "c:/vendors.md", + "reference/a" + NUL + "b.md", + ], +) +def test_a_skill_file_must_stay_inside_the_skill_folder(path: str) -> None: + with pytest.raises(ValidationError, match="escapes the skill folder"): + Skill(name="invoice-triage", description="d", files=["SKILL.md", path]) + + +def test_a_bundle_has_a_virtual_root_and_a_label() -> None: + bundle = SkillBundle(ref=SkillBundleRef(name="finance"), version="3", skills=[]) + assert (bundle.virtual_root, bundle.label) == ("/skills/finance/", "finance v3") + + +# --- Lookup and prompt --- + + +def test_names_find_and_skill_at_keep_same_named_skills_apart() -> None: + skills = _fixture_set(root=None) + finance, ops = skills.bundles + assert skills.names() == [ + "finance/invoice-triage", + "finance/vendor-lookup", + "ops/vendor-lookup", + ] + assert skills.find("finance/vendor-lookup") == (finance, finance.skills[1]) + assert skills.find("ops/vendor-lookup") == (ops, ops.skills[0]) + for missing in ["finance/nope", "nope/vendor-lookup", "vendor-lookup", ""]: + assert skills.find(missing) is None + assert skills.skill_at("/skills/ops/vendor-lookup/SKILL.md") == ( + ops, + ops.skills[0], + ) + # A relative path is read from the root, as a virtual filesystem does. + assert skills.skill_at("skills/finance/vendor-lookup/SKILL.md") == ( + finance, + finance.skills[1], + ) + for path in [ + "/skills/finance/nope/SKILL.md", + "/skills/finance/invoice-triage/reference/vendors.md", + "/notes.md", + ]: + assert skills.skill_at(path) is None + + +def test_the_prompt_section_lists_every_skill_on_one_line() -> None: + section = _fixture_set(root=None).prompt_section() + lines = section.splitlines() + assert lines[0] == "## Skills" + assert "`/`" in section + assert lines[-3:] == [ + "- **finance/invoice-triage** (bundle v3): Triage invoices", + "- **finance/vendor-lookup** (bundle v3): Look up a vendor", + "- **ops/vendor-lookup** (bundle v1): Ops vendor lookup", + ] + + +def test_the_prompt_section_is_empty_without_skills() -> None: + hollow = SkillBundle(ref=SkillBundleRef(name="hollow"), version="2", skills=[]) + assert SkillSet(source="local", bundles=[]).prompt_section() == "" + assert SkillSet(source="local", bundles=[hollow]).prompt_section() == "" + + +# --- Manifest --- + + +def test_the_manifest_round_trips_without_the_root(tmp_path: Path) -> None: + skills_dir = tmp_path / "skills" + failure = BundleFailure( + ref=SkillBundleRef(name="missing"), reason="not_found", detail="404" + ) + manifest = _fixture_set(root=skills_dir, owns_root=True) + manifest.failures = [failure] + manifest.save(skills_dir) + + raw = (skills_dir / MANIFEST_FILE_NAME).read_bytes() + assert b"\r" not in raw + data = json.loads(raw) + # The layout the advanced harness reads (uipath-agents-python#829). + assert set(data) == {"source", "bundles", "failures"} + assert data["bundles"][0]["ref"] == { + "name": "finance", + "folderPath": None, + "versionMode": "pinned", + "version": "3", + "sourceName": None, + } + assert data["bundles"][0]["skills"][0] == { + "name": "invoice-triage", + "description": "Triage invoices", + "files": ["SKILL.md", "reference/vendors.md"], + } + loaded = SkillSet.load(skills_dir) + assert loaded is not None + assert (loaded.bundles, loaded.failures) == (manifest.bundles, [failure]) + assert (loaded.root, loaded.owns_root) == (skills_dir, False) + + +def test_load_ignores_root_keys_inside_the_manifest(tmp_path: Path) -> None: + _fixture_set(root=None).save(tmp_path) + path = tmp_path / MANIFEST_FILE_NAME + data = json.loads(path.read_text(encoding="utf-8")) + path.write_text( + json.dumps({**data, "root": "/elsewhere", "owns_root": True}), encoding="utf-8" + ) + loaded = SkillSet.load(tmp_path) + assert loaded is not None + assert (loaded.root, loaded.owns_root) == (tmp_path, False) + + +def test_load_without_a_manifest_or_with_a_bad_one(tmp_path: Path) -> None: + assert SkillSet.load(tmp_path) is None + (tmp_path / MANIFEST_FILE_NAME).write_text("[1, 2]", encoding="utf-8") + with pytest.raises(ValueError, match="JSON object"): + SkillSet.load(tmp_path) + + +# --- read --- + + +def test_read_returns_a_listed_file_without_a_span( + root: Path, exporter: InMemorySpanExporter, caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level(logging.WARNING): + text = _fixture_set(root).read("finance/invoice-triage", "reference/vendors.md") + expected = root / "finance" / "invoice-triage" / "reference" / "vendors.md" + assert text == expected.read_text(encoding="utf-8") + assert exporter.get_finished_spans() == () + assert caplog.text == "" + + +@pytest.mark.parametrize("invoked_by", ["model", "user"]) +def test_reading_skill_md_records_one_load_span( + root: Path, exporter: InMemorySpanExporter, invoked_by: InvokedBy +) -> None: + _fixture_set(root).save(root) + loaded = SkillSet.load(root) + assert loaded is not None + text = loaded.read("ops/vendor-lookup", invoked_by=invoked_by) + assert text.startswith("---\nname: vendor-lookup\n") + [span] = exporter.get_finished_spans() + assert span.name == "Skill load - ops/vendor-lookup" + attributes = dict(span.attributes or {}) + assert attributes["skill.version"] == "1" + assert attributes["skill.invoked_by"] == invoked_by + assert attributes["skill.bytes"] == len(text.encode("utf-8")) + + +def test_read_drops_a_bom_and_replaces_undecodable_bytes( + root: Path, caplog: pytest.LogCaptureFixture +) -> None: + skill_md = root / "ops" / "vendor-lookup" / "SKILL.md" + skill_md.write_bytes(b"\xef\xbb\xbf---\nname: vendor-lookup\n---\n# V\n") + vendors = root / "finance" / "invoice-triage" / "reference" / "vendors.md" + vendors.write_bytes(b"ACME \xff Corp\n") + skills = _fixture_set(root) + assert skills.read("ops/vendor-lookup") == "---\nname: vendor-lookup\n---\n# V\n" + with caplog.at_level(logging.WARNING): + text = skills.read("finance/invoice-triage", "reference/vendors.md") + assert text == "ACME \ufffd Corp\n" + assert "not valid UTF-8" in caplog.text + + +@pytest.mark.parametrize( + ("name", "path"), + [ + ("finance/nope", "SKILL.md"), + ("nope/vendor-lookup", "SKILL.md"), + # bundle.json exists one level up, but the skill does not list it. + ("finance/invoice-triage", "../bundle.json"), + # On disk, but finance/vendor-lookup lists no files at all. + ("finance/vendor-lookup", "SKILL.md"), + ], +) +def test_read_rejects_an_unknown_skill_or_an_unlisted_file( + root: Path, name: str, path: str +) -> None: + skills = _fixture_set(root) + with pytest.raises(KeyError): + skills.read(name, path) + + +# --- cleanup --- + + +def test_cleanup_removes_only_an_owned_root(tmp_path: Path) -> None: + owned, supplied = tmp_path / "owned", tmp_path / "supplied" + (owned / "finance").mkdir(parents=True) + supplied.mkdir() + owner = SkillSet(source="local", bundles=[], root=owned, owns_root=True) + borrower = SkillSet(source="local", bundles=[], root=supplied) + gone = SkillSet(source="local", bundles=[], root=tmp_path / "gone", owns_root=True) + for skills in (owner, borrower, gone, owner): + skills.cleanup() + assert not owned.exists() + assert supplied.is_dir() + for skills in (owner, borrower, gone): + assert (skills.root, skills.owns_root) == (None, False) + + +def test_read_without_a_root_raises_and_records_nothing( + root: Path, exporter: InMemorySpanExporter +) -> None: + manifest_only = _fixture_set(root=None) + with pytest.raises(RuntimeError, match="not materialized"): + manifest_only.read("finance/invoice-triage") + skills = _fixture_set(root, owns_root=True) + skills.cleanup() + assert not root.exists() + with pytest.raises(RuntimeError, match="cleaned up"): + skills.read("finance/invoice-triage") + assert exporter.get_finished_spans() == () + + +def test_cleanup_warns_when_the_root_cannot_be_removed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + def refuse(path: object, *args: object, **kwargs: object) -> None: + raise OSError("busy") + + monkeypatch.setattr(shutil, "rmtree", refuse) + skills = SkillSet(source="local", bundles=[], root=tmp_path, owns_root=True) + with caplog.at_level(logging.WARNING): + skills.cleanup() + assert "busy" in caplog.text + assert tmp_path.is_dir() + assert (skills.root, skills.owns_root) == (None, False) diff --git a/packages/uipath-platform/tests/services/skills/test_overwrites.py b/packages/uipath-platform/tests/services/skills/test_overwrites.py new file mode 100644 index 000000000..96ae7845a --- /dev/null +++ b/packages/uipath-platform/tests/services/skills/test_overwrites.py @@ -0,0 +1,176 @@ +"""Tests for applying ``skillBundle`` resource overwrites to a bundle reference.""" + +import logging +from typing import Any + +import pytest + +from uipath.platform.common import ResourceOverwrite, ResourceOverwriteParser +from uipath.platform.common._bindings import GenericResourceOverwrite +from uipath.platform.skills import SkillBundleRef, VersionMode +from uipath.platform.skills._overwrites import apply_skill_bundle_overwrite + + +def _ctx(entries: dict[str, dict[str, Any]]) -> dict[str, ResourceOverwrite]: + return {k: ResourceOverwriteParser.parse(k, v) for k, v in entries.items()} + + +def _entry(**fields: Any) -> dict[str, Any]: + """A full overwrite entry: ``finance`` pinned to v3 in ``Shared/Skills``.""" + return { + "name": "finance", + "folderPath": "Shared/Skills", + "versionMode": "pinned", + "version": "3", + **fields, + } + + +def _pinned(**overrides: Any) -> SkillBundleRef: + """A design-time ref: ``finance`` pinned to v3 in ``Shared/Skills``.""" + values: dict[str, Any] = { + "name": "finance", + "folderPath": "Shared/Skills", + "versionMode": "pinned", + "version": "3", + } + values.update(overrides) + return SkillBundleRef.model_validate(values) + + +@pytest.mark.parametrize( + ("entries", "version"), + [ + ( + { + "skillBundle.finance": _entry(version="1"), + "skillBundle.finance.Shared/Skills": _entry(version="2"), + }, + "2", + ), + ({"skillBundle.finance": _entry(version="5")}, "5"), + ({"skillBundle.finance.Shared/Skills": _entry(version="6")}, "6"), + ], + ids=["folder-key-wins", "bare-key", "folder-key-alone"], +) +def test_the_most_specific_key_applies( + entries: dict[str, dict[str, Any]], version: str +) -> None: + out = apply_skill_bundle_overwrite(_pinned(), _ctx(entries)) + assert (out.folder_path, out.version) == ("Shared/Skills", version) + + +def test_a_dotted_bundle_name_gets_its_overwrite() -> None: + ref = SkillBundleRef(name="my.bundle", version_mode=VersionMode.PINNED, version="1") + out = apply_skill_bundle_overwrite( + ref, + _ctx({"skillBundle.my.bundle": _entry(name="my.bundle", version="4")}), + ) + assert (out.name, out.folder_path, out.version) == ( + "my.bundle", + "Shared/Skills", + "4", + ) + + +def test_an_overwrite_replaces_everything_but_the_name() -> None: + ref = _pinned() + out = apply_skill_bundle_overwrite( + ref, + _ctx( + { + "skillBundle.finance": { + "name": "finance-eu", + "folderPath": "Prod", + "versionMode": "latest", + }, + "process.Other": {"name": "x", "folderPath": "y"}, + } + ), + ) + assert (out.name, out.lookup_name, out.key) == ( + "finance", + "finance-eu", + "skillBundle.finance", + ) + assert (out.folder_path, out.version_mode, out.version) == ( + "Prod", + VersionMode.LATEST, + None, + ) + assert ref == _pinned() + + +def test_an_overwrite_matching_the_declared_ref_changes_nothing() -> None: + out = apply_skill_bundle_overwrite( + _pinned(), _ctx({"skillBundle.finance": _entry()}) + ) + assert out == _pinned() + assert out.source_name is None + + +@pytest.mark.parametrize( + "overwrites", + [ + None, + {}, + _ctx({"skillBundle.ops": _entry(name="ops", version="9")}), + _ctx({"skillBundle.finance.Other": _entry(version="8")}), + _ctx({"asset.finance": {"name": "B", "folderPath": "F"}}), + { + "skillBundle.finance": GenericResourceOverwrite( + resource_type="asset", name="B", folder_path="F" + ) + }, + ], + ids=[ + "no-context", + "empty", + "other-bundle", + "other-folder", + "other-kind", + "wrong-type-under-key", + ], +) +def test_nothing_matching_returns_the_ref_itself( + overwrites: dict[str, ResourceOverwrite] | None, +) -> None: + ref = _pinned() + assert apply_skill_bundle_overwrite(ref, overwrites) is ref + + +def test_a_folder_key_is_not_tried_for_a_ref_without_a_folder() -> None: + ref = SkillBundleRef(name="finance") + ows = _ctx({"skillBundle.finance.Shared/Skills": _entry(version="8")}) + assert apply_skill_bundle_overwrite(ref, ows) is ref + + +@pytest.mark.parametrize( + ("entries", "message"), + [ + ( + {"skillBundle.finance": _entry(name="../x")}, + "Ignoring invalid skillBundle overwrite 'skillBundle.finance' for " + "'finance': Value error, a skill bundle name must be a single folder name", + ), + ( + { + "skillBundle.finance.Shared/Skills": _entry(name="../x"), + "skillBundle.finance": _entry(version="5"), + }, + "Ignoring invalid skillBundle overwrite 'skillBundle.finance.Shared/Skills' " + "for 'finance': Value error, a skill bundle name must be a single folder " + "name; the less specific key 'skillBundle.finance' was not tried", + ), + ], + ids=["unsafe-name", "invalid-folder-key"], +) +def test_an_invalid_overwrite_is_ignored_with_one_warning( + caplog: pytest.LogCaptureFixture, + entries: dict[str, dict[str, Any]], + message: str, +) -> None: + ref = _pinned(versionMode="latest", version=None) + with caplog.at_level(logging.WARNING): + assert apply_skill_bundle_overwrite(ref, _ctx(entries)) is ref + assert [r.getMessage() for r in caplog.records] == [message] diff --git a/packages/uipath-platform/tests/services/skills/test_skill_md.py b/packages/uipath-platform/tests/services/skills/test_skill_md.py new file mode 100644 index 000000000..8f9bb079d --- /dev/null +++ b/packages/uipath-platform/tests/services/skills/test_skill_md.py @@ -0,0 +1,48 @@ +"""Tests for reading SKILL.md frontmatter.""" + +import pytest + +from uipath.platform.skills._skill_md import parse_skill_description + +FRONTMATTER = ["---", "name: triage", "description: Classify invoices.", "---", "# T"] + + +@pytest.mark.parametrize( + ("content", "expected"), + [ + ("\n".join(FRONTMATTER), "Classify invoices."), + ("\r\n".join(FRONTMATTER), "Classify invoices."), + ("" + "\n".join(FRONTMATTER), "Classify invoices."), + ("" + "\r\n".join(FRONTMATTER), "Classify invoices."), + ("---\ndescription: ' padded '\n---\n", "padded"), + ("# Triage\n\nNo frontmatter here.\n", ""), + ("---\n- a\n- b\n---\n", ""), + ("---\nname: triage\n---\n", ""), + ("---\nname: [unclosed\n---\n", ""), + ("---\n\n\ndescription: y\n---\n", "y"), + ("---\n\n---\ndescription: y\n---\n", "y"), + ("---\ndescription: y\n---", ""), + ], + ids=[ + "lf", + "crlf", + "bom-lf", + "bom-crlf", + "stripped", + "no-frontmatter", + "not-a-mapping", + "no-description", + "invalid-yaml", + "blank-lines-after-the-fence", + "a-fence-after-blank-lines-starts-the-yaml", + "the-closing-fence-needs-a-newline", + ], +) +def test_parse_skill_description(content: str, expected: str) -> None: + # The cases follow deepagents' frontmatter regex, ^---\s*\n(.*?)\n---\s*\n. + assert parse_skill_description(content) == expected + + +def test_a_large_file_without_a_closing_fence_is_read_in_linear_time() -> None: + # deepagents' regex backtracks for minutes on this. + assert parse_skill_description("---\n" + "\n" * 200_000 + "x") == "" diff --git a/packages/uipath-platform/tests/services/skills/test_skills_service.py b/packages/uipath-platform/tests/services/skills/test_skills_service.py new file mode 100644 index 000000000..4c503f5ed --- /dev/null +++ b/packages/uipath-platform/tests/services/skills/test_skills_service.py @@ -0,0 +1,706 @@ +"""Tests for ``SkillsService``: resolving bound skill bundles into a ``SkillSet``.""" + +import asyncio +import concurrent.futures +import inspect +import logging +import os +import shutil +import sys +import tempfile +import threading +import time +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +import pytest +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + +from uipath.platform.common import ( + ResourceOverwrite, + ResourceOverwriteParser, + UiPathApiConfig, + UiPathExecutionContext, +) +from uipath.platform.common._bindings import _resource_overwrites +from uipath.platform.skills import ( + SkillBundle, + SkillBundleRef, + SkillSet, + VersionMode, + _skills_service, + _sources, +) +from uipath.platform.skills._constants import MANIFEST_FILE_NAME +from uipath.platform.skills._skills_service import ( + SkillBundleLoadError, + SkillsService, + _run_sync, +) +from uipath.platform.skills._sources import BundleSourceError, LocalFolderBundleSource + +# --- Helpers --- + + +def _ref(name: str, **kwargs: Any) -> SkillBundleRef: + return SkillBundleRef(name=name, **kwargs) + + +def _ctx(entries: dict[str, dict[str, Any]]) -> dict[str, ResourceOverwrite]: + return {k: ResourceOverwriteParser.parse(k, v) for k, v in entries.items()} + + +def _attrs(span_or_event: Any) -> Mapping[str, Any]: + return span_or_event.attributes or {} + + +def _only_span(exporter: InMemorySpanExporter) -> ReadableSpan: + [span] = exporter.get_finished_spans() + return span + + +def _failed_events(span: ReadableSpan) -> list[tuple[str, str, str]]: + return [ + ( + _attrs(e)["skill.bundle.name"], + _attrs(e)["skill.reason"], + _attrs(e)["skill.detail"], + ) + for e in span.events + if e.name == "skill.bundle.failed" + ] + + +def _temp_skill_roots(tmp_path: Path) -> set[Path]: + """The ``uipath-skills-*`` folders the service created under ``tmp_path``.""" + return set(tmp_path.glob("uipath-skills-*")) + + +def _write_stale(dest: Path) -> Path: + """A leftover bundle file and manifest from an earlier run.""" + stale = dest / "stale" / "x.txt" + stale.parent.mkdir(parents=True) + stale.write_text("x", encoding="utf-8") + (dest / MANIFEST_FILE_NAME).write_text("{}", encoding="utf-8") + return stale + + +class FakeSource: + """A source that writes a partial bundle, then raises or stalls per bundle name.""" + + name = "fake" + + def __init__( + self, + errors: dict[str, BaseException] | None = None, + delays: dict[str, float] | None = None, + close_error: Exception | None = None, + ) -> None: + self.errors = errors or {} + self.delays = delays or {} + self.close_error = close_error + self.closed = False + self.requested: list[str] = [] + self.dests: list[Path] = [] + self.started = asyncio.Event() + + async def materialize(self, ref: SkillBundleRef, dest: Path) -> SkillBundle: + self.requested.append(ref.lookup_name) + self.dests.append(dest) + dest.mkdir(parents=True) + (dest / "partial.txt").write_text("x", encoding="utf-8") + self.started.set() + if ref.name in self.delays: + await asyncio.sleep(self.delays[ref.name]) + if ref.name in self.errors: + raise self.errors[ref.name] + return SkillBundle(ref=ref, version="1", skills=[]) + + async def aclose(self) -> None: + self.closed = True + if self.close_error is not None: + raise self.close_error + + @property + def root(self) -> Path: + [root] = {d.parent for d in self.dests} + return root + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + tests_data_path: Path, + tmp_path: Path, +) -> SkillsService: + # Owned temp roots land under tmp_path, so leak checks see only this test. + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + monkeypatch.setenv("UIPATH_SKILLS_SOURCE", "local") + monkeypatch.setenv("UIPATH_SKILLS_LOCAL_ROOT", str(tests_data_path / "skills")) + return SkillsService(config=config, execution_context=execution_context) + + +# --- Resolving --- + + +async def test_bundles_are_resolved_into_an_owned_temp_root( + service: SkillsService, exporter: InMemorySpanExporter +) -> None: + result = await service.load_all_async( + [_ref("finance"), _ref("ops", version_mode=VersionMode.PINNED, version="1")] + ) + root = result.root + assert root is not None + assert root.name.startswith("uipath-skills-") + assert result.owns_root is True + assert (result.source, result.failures) == ("local", []) + assert [b.label for b in result.bundles] == ["finance v3", "ops v1"] + assert (root / "finance" / "invoice-triage" / "reference" / "vendors.md").is_file() + saved = SkillSet.load(root) + assert saved is not None + assert saved.bundles == result.bundles + span = _only_span(exporter) + assert span.name == "Skill bundles resolved" + assert (_attrs(span)["skill.bundle.count"], _attrs(span)["skill.count"]) == (2, 3) + result.cleanup() + assert not root.exists() + + +async def test_the_context_overwrite_is_applied(service: SkillsService) -> None: + token = _resource_overwrites.set( + _ctx( + { + "skillBundle.finance": { + "name": "ops", + "folderPath": "Shared/Skills", + "versionMode": "latest", + } + } + ) + ) + try: + result = await service.load_all_async([_ref("finance")]) + finally: + _resource_overwrites.reset(token) + try: + [bundle] = result.bundles + assert (bundle.ref.name, bundle.ref.lookup_name) == ("finance", "ops") + assert result.names() == ["finance/vendor-lookup"] + assert result.root is not None + assert not (result.root / "ops").exists() + finally: + result.cleanup() + + +async def test_a_caller_destination_is_reset_and_kept( + service: SkillsService, tmp_path: Path +) -> None: + dest = tmp_path / "run" / "skills" + stale = _write_stale(dest) + result = await service.load_all_async([_ref("finance")], destination=dest) + assert (result.root, result.owns_root) == (dest, False) + assert not stale.exists() + loaded = SkillSet.load(dest) + assert loaded is not None + assert loaded.names() == result.names() + result.cleanup() + assert (dest / MANIFEST_FILE_NAME).is_file() + + +async def test_a_missing_bundle_is_reported_and_the_rest_continue( + service: SkillsService, exporter: InMemorySpanExporter +) -> None: + result = await service.load_all_async([_ref("ghost"), _ref("finance")]) + try: + assert [b.ref.name for b in result.bundles] == ["finance"] + assert [(f.ref.name, f.reason) for f in result.failures] == [ + ("ghost", "not_found") + ] + assert result.root is not None + assert not (result.root / "ghost").exists() + assert [e.name for e in _only_span(exporter).events] == [ + "skill.bundle.failed", + "skill.bundle.resolved", + ] + finally: + result.cleanup() + + +async def test_duplicate_skill_names_are_reported( + service: SkillsService, caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level(logging.WARNING): + result = await service.load_all_async([_ref("finance"), _ref("ops")]) + result.cleanup() + assert ( + "Skill 'vendor-lookup' exists in bundles 'finance' and 'ops'; both are " + "available to the model as 'finance/vendor-lookup' and 'ops/vendor-lookup'" + ) in caplog.text + + +# --- No references --- + + +async def test_no_refs_gives_an_empty_set_without_a_span( + service: SkillsService, + exporter: InMemorySpanExporter, + caplog: pytest.LogCaptureFixture, + tmp_path: Path, +) -> None: + source = FakeSource(close_error=RuntimeError("close")) + with caplog.at_level(logging.WARNING): + result = await service.load_all_async([], source=source) + assert result == SkillSet(source="fake", bundles=[], failures=[]) + assert result.root is None + assert (source.closed, source.requested) == (True, []) + assert "Closing the skills source fake failed" in caplog.text + assert exporter.get_finished_spans() == () + assert _temp_skill_roots(tmp_path) == set() + + +async def test_no_refs_still_resets_a_caller_destination( + service: SkillsService, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + dest = tmp_path / "skills" + stale = _write_stale(dest) + result = await service.load_all_async([], destination=dest) + assert not stale.exists() + assert not (dest / MANIFEST_FILE_NAME).exists() + assert (result.bundles, result.root) == ([], None) + # A destination that is a file cannot be reset; it is left alone. + assert not dest.exists() + dest.write_text("not a folder", encoding="utf-8") + with caplog.at_level(logging.WARNING): + result = await service.load_all_async([], destination=dest) + assert result.root is None + assert f"Could not remove {dest}" in caplog.text + assert dest.read_text(encoding="utf-8") == "not a folder" + + +# --- Failures --- + + +async def test_a_failed_bundle_leaves_no_partial_folder( + service: SkillsService, +) -> None: + source = FakeSource( + errors={ + "x": RuntimeError("boom"), + "y": BundleSourceError("over budget", reason="too_large"), + } + ) + result = await service.load_all_async( + [_ref("x"), _ref("y"), _ref("z")], source=source + ) + try: + assert [(f.ref.name, f.reason, f.detail) for f in result.failures] == [ + ("x", "source_unavailable", repr(RuntimeError("boom"))), + ("y", "too_large", "over budget"), + ] + assert result.root is not None + assert sorted(p.name for p in result.root.iterdir()) == [ + MANIFEST_FILE_NAME, + "z", + ] + assert source.closed + finally: + result.cleanup() + + +async def test_nothing_resolving_removes_the_owned_temp_root( + service: SkillsService, +) -> None: + source = FakeSource(errors={"x": RuntimeError("boom")}) + result = await service.load_all_async([_ref("x")], source=source) + assert (result.bundles, result.root, result.owns_root) == ([], None, False) + assert not source.root.exists() + assert result.prompt_section() == "" + + +async def test_a_slow_bundle_times_out_and_the_rest_are_skipped( + service: SkillsService, + monkeypatch: pytest.MonkeyPatch, + exporter: InMemorySpanExporter, +) -> None: + monkeypatch.setattr(_skills_service, "BUNDLE_RESOLVE_TIMEOUT_SECONDS", 0.05) + source = FakeSource(delays={"slow": 1}) + result = await service.load_all_async( + [_ref("fast"), _ref("slow"), _ref("after")], source=source + ) + try: + assert [b.ref.name for b in result.bundles] == ["fast"] + assert source.requested == ["fast", "slow"] + assert result.root is not None + assert not (result.root / "slow").exists() + assert _failed_events(_only_span(exporter)) == [ + ("slow", "source_unavailable", "timed out after 0.05s"), + ("after", "source_unavailable", "skipped: skills source timed out"), + ] + finally: + result.cleanup() + + +async def test_the_local_source_honours_the_time_budget( + service: SkillsService, + root: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(_skills_service, "BUNDLE_RESOLVE_TIMEOUT_SECONDS", 0.05) + real = shutil.copyfile + + def slow(src: Path, dst: Path) -> object: + time.sleep(0.2) + return real(src, dst) + + monkeypatch.setattr(f"{_sources.__name__}.shutil.copyfile", slow) + dest = tmp_path / "run" + result = await service.load_all_async( + [_ref("finance"), _ref("ops")], + destination=dest, + source=LocalFolderBundleSource(root), + ) + assert result.bundles == [] + assert [(f.ref.name, f.detail) for f in result.failures] == [ + ("finance", "timed out after 0.05s"), + ("ops", "skipped: skills source timed out"), + ] + # The stopped copy wrote nothing after the bundle folder was removed. + await asyncio.sleep(0.3) + assert not (dest / "finance").exists() + + +async def test_a_timeout_raised_by_the_source_does_not_skip_the_rest( + service: SkillsService, +) -> None: + source = FakeSource(errors={"a": TimeoutError("upstream")}) + result = await service.load_all_async([_ref("a"), _ref("b")], source=source) + try: + assert source.requested == ["a", "b"] + assert [b.ref.name for b in result.bundles] == ["b"] + assert [(f.ref.name, f.detail) for f in result.failures] == [ + ("a", repr(TimeoutError("upstream"))) + ] + finally: + result.cleanup() + + +async def test_a_source_that_cannot_be_built_fails_every_bundle( + service: SkillsService, + monkeypatch: pytest.MonkeyPatch, + exporter: InMemorySpanExporter, + tmp_path: Path, +) -> None: + def broken(*args: Any, **kwargs: Any) -> Any: + raise RuntimeError("no platform config") + + monkeypatch.setattr(_skills_service, "get_bundle_source", broken) + monkeypatch.setenv("UIPATH_SKILLS_SOURCE", " Orchestrator ") + dest = tmp_path / "skills" + stale = _write_stale(dest) + result = await service.load_all_async( + [_ref("finance"), _ref("ops")], destination=dest + ) + # The destination is reset before the source is built. + assert not stale.exists() + assert (result.bundles, result.root, result.source) == ([], None, "orchestrator") + for failure in result.failures: + assert failure.reason == "source_unavailable" + assert failure.detail.startswith("skills source unavailable: RuntimeError(") + span = _only_span(exporter) + assert _attrs(span)["skill.source"] == "orchestrator" + assert [e.name for e in span.events] == ["skill.bundle.failed"] * 2 + + +async def test_a_temp_folder_that_cannot_be_created_skips_every_bundle( + service: SkillsService, + monkeypatch: pytest.MonkeyPatch, + exporter: InMemorySpanExporter, +) -> None: + def full(*args: Any, **kwargs: Any) -> str: + raise OSError(28, "No space left on device") + + monkeypatch.setattr(tempfile, "mkdtemp", full) + source = FakeSource() + result = await service.load_all_async([_ref("finance"), _ref("ops")], source=source) + assert (result.bundles, result.root) == ([], None) + assert [(f.ref.name, f.reason) for f in result.failures] == [ + ("finance", "source_unavailable"), + ("ops", "source_unavailable"), + ] + assert "cannot create the skills folder" in result.failures[0].detail + assert (source.closed, source.requested) == (True, []) + span = _only_span(exporter) + assert _attrs(span)["skill.source"] == "fake" + assert [e.name for e in span.events] == ["skill.bundle.failed"] * 2 + + +async def test_a_failing_aclose_keeps_the_result( + service: SkillsService, caplog: pytest.LogCaptureFixture +) -> None: + source = FakeSource(close_error=RuntimeError("close")) + with caplog.at_level(logging.WARNING): + result = await service.load_all_async([_ref("x")], source=source) + try: + assert [b.ref.name for b in result.bundles] == ["x"] + assert "Closing the skills source fake failed" in caplog.text + finally: + result.cleanup() + + +@pytest.mark.parametrize( + "second", + [_ref("x", version_mode=VersionMode.PINNED, version="2"), _ref("X")], + ids=["same-name", "case-variant"], +) +async def test_a_repeated_bundle_name_is_invalid( + service: SkillsService, second: SkillBundleRef +) -> None: + source = FakeSource() + result = await service.load_all_async([_ref("x"), second], source=source) + try: + assert source.requested == ["x"] + assert [b.ref.name for b in result.bundles] == ["x"] + assert [(f.ref, f.reason, f.detail) for f in result.failures] == [ + (second, "invalid", "repeated bundle name; the first reference was kept") + ] + assert result.root is not None + assert (result.root / "x" / "partial.txt").is_file() + finally: + result.cleanup() + + +async def test_a_bundle_folder_that_already_exists_is_a_repeat( + service: SkillsService, +) -> None: + class AliasingSource(FakeSource): + """Writes bundle ``x`` and, as a folded-name filesystem would, ``y`` too.""" + + async def materialize(self, ref: SkillBundleRef, dest: Path) -> SkillBundle: + bundle = await super().materialize(ref, dest) + (dest.parent / "y").mkdir() + return bundle + + source = AliasingSource() + result = await service.load_all_async([_ref("x"), _ref("y")], source=source) + try: + assert source.requested == ["x"] + assert [(f.ref.name, f.reason) for f in result.failures] == [("y", "invalid")] + finally: + result.cleanup() + + +# --- The root --- + + +async def test_a_destination_that_cannot_be_reset_fails_every_bundle( + service: SkillsService, tmp_path: Path, exporter: InMemorySpanExporter +) -> None: + dest = tmp_path / "skills" + dest.write_text("not a folder", encoding="utf-8") + source = FakeSource() + result = await service.load_all_async( + [_ref("x"), _ref("y")], destination=dest, source=source + ) + assert (result.bundles, result.root) == ([], None) + assert (source.requested, source.closed) == ([], True) + assert _failed_events(_only_span(exporter)) == [ + ("x", "invalid", "stale files could not be removed"), + ("y", "invalid", "stale files could not be removed"), + ] + assert dest.read_text(encoding="utf-8") == "not a folder" + + +async def test_a_symlinked_destination_is_not_followed( + service: SkillsService, tmp_path: Path +) -> None: + target = tmp_path / "target" + target.mkdir() + (target / "keep.txt").write_text("keep", encoding="utf-8") + link = tmp_path / "skills" + _link_directory(target, link) + result = await service.load_all_async([_ref("finance")], destination=link) + assert (result.bundles, result.root) == ([], None) + assert [(f.reason, f.detail) for f in result.failures] == [ + ("invalid", "stale files could not be removed") + ] + assert (target / "keep.txt").read_text(encoding="utf-8") == "keep" + assert os.path.lexists(link) + + +def _link_directory(target: Path, link: Path) -> None: + """A symlink, or on Windows without the symlink privilege a junction.""" + try: + os.symlink(target, link, target_is_directory=True) + return + except (OSError, NotImplementedError) as e: + if sys.platform != "win32": + pytest.skip(f"cannot create a directory symlink here: {e}") + import _winapi + + _winapi.CreateJunction(str(target), str(link)) + + +async def test_an_unexpected_error_leaves_no_skills_and_does_not_raise( + service: SkillsService, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + def broken_save(self: SkillSet, skills_dir: Path) -> None: + raise OSError("disk full") + + monkeypatch.setattr(SkillSet, "save", broken_save) + source = FakeSource() + with caplog.at_level(logging.ERROR): + result = await service.load_all_async( + [_ref("finance"), _ref("ops")], source=source + ) + assert (result.bundles, result.root) == ([], None) + assert [(f.ref.name, f.reason, f.detail) for f in result.failures] == [ + ("finance", "source_unavailable", "unexpected error: OSError('disk full')"), + ("ops", "source_unavailable", "unexpected error: OSError('disk full')"), + ] + assert "continuing without skills" in caplog.text + assert source.closed + assert not source.root.exists() + + +async def test_cancellation_removes_the_partial_bundle_and_propagates( + service: SkillsService, +) -> None: + source = FakeSource(delays={"x": 10}) + task = asyncio.create_task(service.load_all_async([_ref("x")], source=source)) + await source.started.wait() + [dest] = source.dests + assert (dest / "partial.txt").is_file() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert not source.root.exists() + assert source.closed + + +# --- load_async --- + + +async def test_load_async_returns_one_bundle( + service: SkillsService, tmp_path: Path +) -> None: + dest = tmp_path / "skills" + pinned = await service.load_async( + "ops", version_mode="pinned", version="1", destination=dest + ) + assert [b.label for b in pinned.bundles] == ["ops v1"] + assert (pinned.root, pinned.owns_root) == (dest, False) + latest = await service.load_async("finance") + assert latest.bundles[0].ref.version_mode is VersionMode.LATEST + assert latest.owns_root is True + assert latest.root is not None + assert latest.root.parent == tmp_path + latest.cleanup() + assert _temp_skill_roots(tmp_path) == set() + + +async def test_load_async_raises_for_a_missing_bundle_and_leaves_no_temp_root( + service: SkillsService, tmp_path: Path +) -> None: + with pytest.raises(SkillBundleLoadError) as info: + await service.load_async("ghost", folder_path="Shared/Skills") + assert _temp_skill_roots(tmp_path) == set() + failure = info.value.failure + assert (failure.reason, failure.ref.name, failure.ref.folder_path) == ( + "not_found", + "ghost", + "Shared/Skills", + ) + assert str(info.value).startswith("skill bundle 'ghost' not_found: ") + + +async def test_load_async_rejects_a_bad_reference(service: SkillsService) -> None: + with pytest.raises(ValueError, match="needs a version"): + await service.load_async("finance", version_mode="pinned") + with pytest.raises(ValueError): + await service.load_async("finance", version_mode="newest") + + +# --- Sync twins --- + + +def test_the_sync_twins_run_without_an_event_loop( + service: SkillsService, tmp_path: Path +) -> None: + result = service.load_all([_ref("finance")]) + assert result.names() == ["finance/invoice-triage", "finance/vendor-lookup"] + result.cleanup() + one = service.load("finance", destination=tmp_path / "skills") + assert [b.label for b in one.bundles] == ["finance v3"] + with pytest.raises(SkillBundleLoadError): + service.load("ghost") + assert _temp_skill_roots(tmp_path) == set() + + +async def test_load_all_from_a_running_loop_sees_the_context_overwrite( + service: SkillsService, +) -> None: + token = _resource_overwrites.set( + _ctx( + { + "skillBundle.finance": { + "name": "ops", + "folderPath": "Shared/Skills", + "versionMode": "latest", + } + } + ) + ) + try: + result = service.load_all([_ref("finance")]) + finally: + _resource_overwrites.reset(token) + try: + assert result.bundles[0].ref.lookup_name == "ops" + finally: + result.cleanup() + + +async def test_run_sync_from_a_loop_uses_another_thread_and_re_raises() -> None: + async def where() -> int: + return threading.get_ident() + + async def boom() -> None: + raise ValueError("inside") + + assert _run_sync(where()) != threading.get_ident() + failing = boom() + with pytest.raises(ValueError, match="inside"): + _run_sync(failing) + + +def test_run_sync_without_a_loop_stays_on_this_thread() -> None: + async def where() -> int: + return threading.get_ident() + + assert _run_sync(where()) == threading.get_ident() + + +async def test_run_sync_closes_the_coroutine_when_nothing_can_run_it( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def refuse(self: Any, fn: Any, /, *args: Any, **kwargs: Any) -> Any: + raise RuntimeError("cannot schedule new futures after interpreter shutdown") + + monkeypatch.setattr(concurrent.futures.ThreadPoolExecutor, "submit", refuse) + + async def never_runs() -> None: + return None + + coro = never_runs() + with pytest.raises(RuntimeError, match="interpreter shutdown"): + _run_sync(coro) + assert inspect.getcoroutinestate(coro) == inspect.CORO_CLOSED diff --git a/packages/uipath-platform/tests/services/skills/test_sources_local.py b/packages/uipath-platform/tests/services/skills/test_sources_local.py new file mode 100644 index 000000000..4fd4ad037 --- /dev/null +++ b/packages/uipath-platform/tests/services/skills/test_sources_local.py @@ -0,0 +1,282 @@ +"""Tests for the local-folder skill bundle source.""" + +import asyncio +import logging +import shutil +import threading +import time +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from uipath.platform.skills import _sources +from uipath.platform.skills._layout import iter_skill_files +from uipath.platform.skills._sources import BundleSourceError, LocalFolderBundleSource +from uipath.platform.skills.skills import SkillBundle, SkillBundleRef + +HEALTHY = ["invoice-triage", "vendor-lookup"] +COPYFILE = f"{_sources.__name__}.shutil.copyfile" + + +def _add_skill(root: Path, name: str, content: bytes) -> None: + (root / "finance" / name).mkdir() + (root / "finance" / name / "SKILL.md").write_bytes(content) + + +async def _materialize(root: Path, dest: Path, **ref: object) -> SkillBundle: + values = {"name": "finance", **ref} + return await LocalFolderBundleSource(root).materialize( + SkillBundleRef.model_validate(values), dest + ) + + +async def _names(root: Path, dest: Path) -> list[str]: + return [s.name for s in (await _materialize(root, dest)).skills] + + +# --- What is copied --- + + +async def test_a_bundle_is_copied_with_its_version_and_descriptions( + root: Path, tmp_path: Path +) -> None: + dest = tmp_path / "run" / "skills" / "finance" + bundle = await _materialize(root, dest, versionMode="pinned", version="3") + assert bundle.version == "3" + assert [s.name for s in bundle.skills] == HEALTHY + triage = bundle.skills[0] + assert triage.files == ["SKILL.md", "reference/vendors.md"] + assert triage.description.startswith("Classify an incoming invoice") + assert (dest / "invoice-triage" / "reference" / "vendors.md").is_file() + assert not (dest / "bundle.json").exists() + + +async def test_a_pinned_version_the_folder_lacks_loads_anyway_with_a_warning( + root: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level(logging.WARNING): + bundle = await _materialize( + root, tmp_path / "d", versionMode="pinned", version="99" + ) + assert bundle.version == "3" + assert "pinned to version 99" in caplog.text + + +async def test_a_source_name_retargets_the_lookup(root: Path, tmp_path: Path) -> None: + dest = tmp_path / "d" + bundle = await _materialize(root, dest, sourceName="ops") + assert (bundle.ref.name, bundle.version) == ("finance", "1") + assert [s.name for s in bundle.skills] == ["vendor-lookup"] + assert bundle.skills[0].description.startswith("(ops copy)") + assert not (dest / "invoice-triage").exists() + + +async def test_only_skill_folders_and_visible_files_are_copied( + root: Path, tmp_path: Path +) -> None: + finance = root / "finance" + (finance / "notes").mkdir() + (finance / "notes" / "readme.txt").write_text("x") + _add_skill(root, ".hidden", b"---\ndescription: x\n---\n") + triage = finance / "invoice-triage" + (triage / ".git").mkdir() + (triage / ".git" / "config").write_text("x") + (triage / "reference" / ".notes.md").write_text("x") + dest = tmp_path / "d" + bundle = await _materialize(root, dest) + assert [s.name for s in bundle.skills] == HEALTHY + assert bundle.skills[0].files == ["SKILL.md", "reference/vendors.md"] + assert sorted(p.name for p in dest.iterdir()) == HEALTHY + assert not (dest / "invoice-triage" / ".git").exists() + + +async def test_skills_are_ordered_by_code_point(root: Path, tmp_path: Path) -> None: + # Windows path ordering would ignore case. + _add_skill(root, "Zeta", b"---\ndescription: z\n---\n") + _add_skill(root, "alpha", b"---\ndescription: a\n---\n") + assert await _names(root, tmp_path / "d") == ["Zeta", "alpha", *HEALTHY] + + +async def test_a_bundle_without_a_usable_skill_still_gets_its_folder( + root: Path, tmp_path: Path +) -> None: + (root / "empty").mkdir() + bundle = await _materialize(root, tmp_path / "d", name="empty") + assert bundle.skills == [] + assert (tmp_path / "d").is_dir() + + +async def test_copied_files_are_writable(root: Path, tmp_path: Path) -> None: + source_file = root / "finance" / "vendor-lookup" / "SKILL.md" + source_file.chmod(0o444) + try: + await _materialize(root, tmp_path / "d") + finally: + source_file.chmod(0o644) + (tmp_path / "d" / "vendor-lookup" / "SKILL.md").write_text("ok", encoding="utf-8") + + +async def test_a_lower_case_skill_md_is_copied_as_skill_md( + root: Path, tmp_path: Path +) -> None: + (root / "finance" / "lower").mkdir() + (root / "finance" / "lower" / "skill.md").write_bytes( + b"---\ndescription: lower\n---\n" + ) + (root / "finance" / "lower" / "notes.md").write_text("x", encoding="utf-8") + dest = tmp_path / "d" + bundle = await _materialize(root, dest) + lower = next(s for s in bundle.skills if s.name == "lower") + assert (lower.description, lower.files) == ("lower", ["SKILL.md", "notes.md"]) + assert sorted(p.name for p in (dest / "lower").iterdir()) == [ + "SKILL.md", + "notes.md", + ] + + +async def test_a_second_spelling_of_skill_md_is_left_out( + root: Path, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # A case-sensitive file system can hold SKILL.md and skill.md side by + # side; fake that listing so the test runs on Windows and macOS too. + def with_a_second_spelling(skill_dir: Path) -> list[str]: + files = iter_skill_files(skill_dir) + return [files[0], "skill.md", *files[1:]] + + monkeypatch.setattr(f"{_sources.__name__}.iter_skill_files", with_a_second_spelling) + with caplog.at_level(logging.WARNING): + bundle = await _materialize(root, tmp_path / "d") + for skill in bundle.skills: + assert skill.files[0] == "SKILL.md" + assert "skill.md" not in skill.files + assert "the skill already has SKILL.md" in caplog.text + + +# --- What is skipped --- + + +async def test_an_oversized_file_is_skipped( + root: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + (root / "finance" / "invoice-triage" / "big.bin").write_bytes( + b"0" * (512 * 1024 + 1) + ) + with caplog.at_level(logging.WARNING): + bundle = await _materialize(root, tmp_path / "d") + assert "big.bin" not in bundle.skills[0].files + assert not (tmp_path / "d" / "invoice-triage" / "big.bin").exists() + assert "Skipping invoice-triage/big.bin in bundle 'finance'" in caplog.text + + +@pytest.mark.parametrize( + "skill_md", + [ + b"---\ndescription: x\n---\n" + b"0" * (512 * 1024), + b"---\ndescription: caf\xe9\n---\n", + ], + ids=["oversized", "not-utf8"], +) +async def test_an_unusable_skill_md_skips_only_that_skill( + root: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture, skill_md: bytes +) -> None: + _add_skill(root, "bad", skill_md) + with caplog.at_level(logging.WARNING): + assert await _names(root, tmp_path / "d") == HEALTHY + assert not (tmp_path / "d" / "bad").exists() + assert "Skipping skill 'bad' in bundle 'finance'" in caplog.text + + +# --- Errors --- + + +async def test_an_unknown_bundle_is_not_found(root: Path, tmp_path: Path) -> None: + with pytest.raises(BundleSourceError) as info: + await _materialize(root, tmp_path / "d", name="ghost") + assert info.value.reason == "not_found" + assert not (tmp_path / "d").exists() + + +async def test_a_bundle_over_the_size_limit_is_too_large( + root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(_sources, "MAX_BUNDLE_BYTES", 10) + with pytest.raises(BundleSourceError) as info: + await _materialize(root, tmp_path / "d") + assert info.value.reason == "too_large" + + +@pytest.mark.parametrize("failing", ["iterdir", "copyfile"]) +async def test_a_filesystem_error_is_source_unavailable( + root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failing: str +) -> None: + def denied(*args: object) -> Iterator[Path]: + raise PermissionError(13, "Permission denied") + + if failing == "iterdir": + monkeypatch.setattr(Path, "iterdir", denied) + else: + monkeypatch.setattr(COPYFILE, denied) + with pytest.raises(BundleSourceError) as info: + await _materialize(root, tmp_path / "d") + assert info.value.reason == "source_unavailable" + assert "finance" in info.value.detail + assert isinstance(info.value.__cause__, PermissionError) + + +# --- The worker thread --- + + +async def test_the_copy_runs_off_the_event_loop( + root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + real = shutil.copyfile + + def slow(src: Path, dst: Path) -> object: + time.sleep(0.1) + return real(src, dst) + + monkeypatch.setattr(COPYFILE, slow) + ticks = 0 + + async def ticker() -> None: + nonlocal ticks + while True: + ticks += 1 + await asyncio.sleep(0.01) + + task = asyncio.create_task(ticker()) + try: + assert await _names(root, tmp_path / "d") == HEALTHY + finally: + task.cancel() + # Three files at 0.1 s each; a blocked loop would have ticked once. + assert ticks >= 5 + + +async def test_cancelling_stops_the_copy_before_the_next_file( + root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + real = shutil.copyfile + started = threading.Event() + copied: list[str] = [] + + def slow(src: Path, dst: Path) -> object: + started.set() + time.sleep(0.2) + copied.append(Path(dst).name) + return real(src, dst) + + monkeypatch.setattr(COPYFILE, slow) + task = asyncio.create_task(_names(root, tmp_path / "d")) + await asyncio.to_thread(started.wait) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + # The file in flight finished before materialize returned; no other started. + assert len(copied) == 1 + await asyncio.sleep(0.3) + assert len(copied) == 1 diff --git a/packages/uipath-platform/tests/services/skills/test_telemetry.py b/packages/uipath-platform/tests/services/skills/test_telemetry.py new file mode 100644 index 000000000..c9e508dd6 --- /dev/null +++ b/packages/uipath-platform/tests/services/skills/test_telemetry.py @@ -0,0 +1,214 @@ +"""Tests for skill bundle telemetry: the span names, types and attributes LLMOps reads.""" + +import json +import logging +from collections.abc import Mapping + +import pytest +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from opentelemetry.trace import StatusCode + +from uipath.platform.skills import ( + BundleFailure, + Skill, + SkillBundle, + SkillBundleRef, + SkillSet, + VersionMode, + _telemetry, +) +from uipath.platform.skills._telemetry import ( + bundles_resolved_span, + record_bundle_failed, + record_bundles_resolved, + record_skill_loaded, + skill_load_span, +) + +FINANCE = SkillBundle( + ref=SkillBundleRef(name="finance", folder_path="Shared/Skills"), + version="3", + skills=[ + Skill(name="invoice-triage", description="d", files=["SKILL.md"]), + Skill(name="vendor-lookup", description="d", files=["SKILL.md"]), + ], +) +OPS = SkillBundle( + ref=SkillBundleRef(name="ops", source_name="ops-prod"), + version="1", + skills=[Skill(name="vendor-lookup", description="d")], +) +GHOST = BundleFailure( + ref=SkillBundleRef( + name="ghost", folder_path="F", version_mode=VersionMode.PINNED, version="7" + ), + reason="not_found", + detail="no such bundle", +) + + +def _only_span(exporter: InMemorySpanExporter) -> ReadableSpan: + [span] = exporter.get_finished_spans() + return span + + +def _assert_llmops_span(span: ReadableSpan, span_type: str) -> dict[str, object]: + """The markers the LLMOps exporters keep a span for; OTel takes primitives only.""" + attributes: dict[str, object] = dict(span.attributes or {}) + assert attributes["uipath.custom_instrumentation"] is True + assert attributes["type"] == attributes["span_type"] == span_type + for event in span.events: + _assert_primitives(event.attributes or {}) + _assert_primitives(attributes) + return attributes + + +def _assert_primitives(attributes: Mapping[str, object]) -> None: + for key, value in attributes.items(): + assert isinstance(value, (str, int, bool, float)), key + + +def test_the_resolution_span_describes_every_bundle_and_failure( + exporter: InMemorySpanExporter, caplog: pytest.LogCaptureFixture +) -> None: + result = SkillSet(source="local", bundles=[FINANCE, OPS], failures=[GHOST]) + with caplog.at_level(logging.INFO): + with bundles_resolved_span() as span: + record_bundle_failed(span, GHOST) + record_bundles_resolved(span, result) + + finished = _only_span(exporter) + assert finished.name == "Skill bundles resolved" + attributes = _assert_llmops_span(finished, "skillBundlesResolved") + assert ( + attributes["skill.source"], + attributes["skill.bundle.count"], + attributes["skill.count"], + ) == ("local", 2, 3) + assert json.loads(str(attributes["skill.failures"])) == [ + { + "bundle": "ghost", + "lookup_name": "ghost", + "folder": "F", + "version": "7", + "reason": "not_found", + } + ] + failed, finance, ops = (dict(e.attributes or {}) for e in finished.events) + assert [e.name for e in finished.events] == [ + "skill.bundle.failed", + "skill.bundle.resolved", + "skill.bundle.resolved", + ] + assert failed == { + "skill.bundle.name": "ghost", + "skill.bundle.lookup_name": "ghost", + "skill.folder": "F", + "skill.version": "7", + "skill.version_mode": "pinned", + "skill.reason": "not_found", + "skill.detail": "no such bundle", + } + assert finance == { + "skill.bundle.name": "finance", + "skill.bundle.lookup_name": "finance", + "skill.folder": "Shared/Skills", + "skill.version": "3", + "skill.version_mode": "latest", + "skill.bundle.skill_count": 2, + } + assert (ops["skill.bundle.lookup_name"], ops["skill.folder"]) == ("ops-prod", "") + assert "Skill bundle 'ghost' skipped (not_found): no such bundle" in caplog.text + assert "finance@3 (2 skills), ops@1 (1 skills)" in caplog.text + + +def test_the_resolution_span_with_nothing_resolved( + exporter: InMemorySpanExporter, caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level(logging.INFO): + with bundles_resolved_span() as span: + record_bundles_resolved(span, SkillSet(source="local", bundles=[])) + attributes = dict(_only_span(exporter).attributes or {}) + assert (attributes["skill.bundle.count"], attributes["skill.count"]) == (0, 0) + assert "Resolved 0 skill bundle(s) from local: none" in caplog.text + + +def test_the_skill_load_span(exporter: InMemorySpanExporter) -> None: + record_skill_loaded(OPS, OPS.skills[0], size=7, invoked_by="user") + + span = _only_span(exporter) + assert span.name == "Skill load - ops/vendor-lookup" + attributes = _assert_llmops_span(span, "skillLoad") + assert attributes == { + "type": "skillLoad", + "span_type": "skillLoad", + "uipath.custom_instrumentation": True, + "skill.bundle.name": "ops", + "skill.bundle.lookup_name": "ops-prod", + "skill.folder": "", + "skill.version": "1", + "skill.version_mode": "latest", + "skill.name": "vendor-lookup", + "skill.invoked_by": "user", + "skill.bytes": 7, + } + + +def test_spans_started_inside_the_skill_load_span_are_its_children( + exporter: InMemorySpanExporter, caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level(logging.INFO): + with skill_load_span(FINANCE, FINANCE.skills[0]) as load: + with _telemetry._tracer.start_as_current_span("Tool call - read_file"): + pass + load.loaded(42) + + child, parent = exporter.get_finished_spans() + assert parent.name == "Skill load - finance/invoice-triage" + assert child.parent is not None + assert child.parent.span_id == parent.context.span_id + attributes = _assert_llmops_span(parent, "skillLoad") + assert attributes["skill.bytes"] == 42 + assert attributes["skill.invoked_by"] == "model" + assert parent.status.status_code is StatusCode.UNSET + assert "Skill loaded: finance/invoice-triage (v3)" in caplog.text + + +def test_a_failed_skill_load_is_an_error_span_without_the_loaded_log( + exporter: InMemorySpanExporter, caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level(logging.INFO): + with skill_load_span(FINANCE, FINANCE.skills[0]) as load: + load.fail("read_file returned an error") + + span = _only_span(exporter) + assert span.status.status_code is StatusCode.ERROR + assert span.status.description == "read_file returned an error" + assert "skill.bytes" not in (span.attributes or {}) + assert "Skill loaded" not in caplog.text + + +def test_a_skill_load_left_unsettled_is_an_error_span( + exporter: InMemorySpanExporter, +) -> None: + with skill_load_span(FINANCE, FINANCE.skills[0]): + pass + + span = _only_span(exporter) + assert span.status.status_code is StatusCode.ERROR + assert span.status.description == "The skill was not loaded" + + +def test_an_exception_inside_the_skill_load_span_is_recorded_and_propagates( + exporter: InMemorySpanExporter, +) -> None: + with pytest.raises(OSError, match="disk gone"): + with skill_load_span(FINANCE, FINANCE.skills[0]): + raise OSError("disk gone") + + span = _only_span(exporter) + assert span.status.status_code is StatusCode.ERROR + assert [event.name for event in span.events] == ["exception"] diff --git a/packages/uipath-platform/tests/services/tests_data/skills/finance/bundle.json b/packages/uipath-platform/tests/services/tests_data/skills/finance/bundle.json new file mode 100644 index 000000000..ec3cb1fde --- /dev/null +++ b/packages/uipath-platform/tests/services/tests_data/skills/finance/bundle.json @@ -0,0 +1,5 @@ +{ + "name": "finance", + "version": "3", + "description": "Accounts-payable skills" +} diff --git a/packages/uipath-platform/tests/services/tests_data/skills/finance/invoice-triage/SKILL.md b/packages/uipath-platform/tests/services/tests_data/skills/finance/invoice-triage/SKILL.md new file mode 100644 index 000000000..8d56f0afd --- /dev/null +++ b/packages/uipath-platform/tests/services/tests_data/skills/finance/invoice-triage/SKILL.md @@ -0,0 +1,22 @@ +--- +name: invoice-triage +description: Classify an incoming invoice, match it to a purchase order and route exceptions. Use when the task mentions invoices, purchase orders or vendor bills. +--- + +# Invoice triage + +Follow these steps in order. Stop and escalate at the first unrecoverable mismatch. + +1. **Identify the vendor.** If the vendor is not already confirmed, load and follow the `vendor-lookup` skill first. +2. **Find the purchase order.** Call the agent's purchase-order tool with the PO number from the invoice. If the invoice has no PO number, search by vendor and amount within ±2 %. +3. **Match line items.** Compare quantity and unit price per line. Tolerances: quantity exact, price ±1 %. +4. **Classify.** + - All lines match → `approved` + - One line outside tolerance → `exception:price` or `exception:quantity` + - No PO found → `exception:no-po` +5. **Route.** Approved invoices go to the payment queue. Exceptions go to the escalation tool with the classification, the invoice id and the specific line. +6. **Report.** Return the classification, the PO number (or `none`) and a one-line reason. + +Never approve an invoice above 10,000 without a matching PO, even if the vendor is trusted. + +See `reference/vendors.md` for the vendor normalization table. diff --git a/packages/uipath-platform/tests/services/tests_data/skills/finance/invoice-triage/reference/vendors.md b/packages/uipath-platform/tests/services/tests_data/skills/finance/invoice-triage/reference/vendors.md new file mode 100644 index 000000000..54b0f25cd --- /dev/null +++ b/packages/uipath-platform/tests/services/tests_data/skills/finance/invoice-triage/reference/vendors.md @@ -0,0 +1,7 @@ +# Vendor normalization + +| Invoice shows | Canonical vendor | Notes | +| --- | --- | --- | +| ACME Corp., Acme Inc, ACME | Acme Corporation | Tax id ends in 4471 | +| Globex, Globex Intl | Globex International | Net 45 terms | +| Initech LLC | Initech | Prefers PO on every invoice | diff --git a/packages/uipath-platform/tests/services/tests_data/skills/finance/vendor-lookup/SKILL.md b/packages/uipath-platform/tests/services/tests_data/skills/finance/vendor-lookup/SKILL.md new file mode 100644 index 000000000..9f60a1a31 --- /dev/null +++ b/packages/uipath-platform/tests/services/tests_data/skills/finance/vendor-lookup/SKILL.md @@ -0,0 +1,14 @@ +--- +name: vendor-lookup +description: Find the canonical vendor record and its payment terms before acting on an invoice. Use when a vendor name, tax id or supplier number appears. +--- + +# Vendor lookup + +1. Normalize the name you were given: trim legal suffixes (Inc, LLC, Corp, Ltd) and casing. +2. Query the agent's vendor directory tool by tax id when present, otherwise by normalized name. +3. If exactly one record matches, return: canonical name, vendor id, payment terms, and whether the vendor is on hold. +4. If several match, return the candidates with their tax ids and ask the caller to disambiguate. Do not guess. +5. If none match, return `vendor-not-found` and the normalized name you searched for. + +Do not create vendor records from this skill. diff --git a/packages/uipath-platform/tests/services/tests_data/skills/ops/bundle.json b/packages/uipath-platform/tests/services/tests_data/skills/ops/bundle.json new file mode 100644 index 000000000..ada7eb28f --- /dev/null +++ b/packages/uipath-platform/tests/services/tests_data/skills/ops/bundle.json @@ -0,0 +1,5 @@ +{ + "name": "ops", + "version": "1", + "description": "Operations skills" +} diff --git a/packages/uipath-platform/tests/services/tests_data/skills/ops/vendor-lookup/SKILL.md b/packages/uipath-platform/tests/services/tests_data/skills/ops/vendor-lookup/SKILL.md new file mode 100644 index 000000000..64c98fa9d --- /dev/null +++ b/packages/uipath-platform/tests/services/tests_data/skills/ops/vendor-lookup/SKILL.md @@ -0,0 +1,14 @@ +--- +name: vendor-lookup +description: (ops copy) Find the canonical vendor record and its payment terms before acting on an invoice. Use when a vendor name, tax id or supplier number appears. +--- + +# Vendor lookup + +1. Normalize the name you were given: trim legal suffixes (Inc, LLC, Corp, Ltd) and casing. +2. Query the agent's vendor directory tool by tax id when present, otherwise by normalized name. +3. If exactly one record matches, return: canonical name, vendor id, payment terms, and whether the vendor is on hold. +4. If several match, return the candidates with their tax ids and ask the caller to disambiguate. Do not guess. +5. If none match, return `vendor-not-found` and the normalized name you searched for. + +Do not create vendor records from this skill. diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index fc04aa5b1..4f5b5f619 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1040,6 +1040,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20261030" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0c/72b85bc2e3a3cf23419a7f8861c1855893f02080e202b631e33cc0d1a94a/types_pyyaml-6.0.12.20261030.tar.gz", hash = "sha256:9123d707aa31a3883eeb2cb290537c75246dfcb1c00abed7a70f5419d3bd31ae", size = 18878, upload-time = "2026-10-30T07:50:19.43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/99/7e2c7c7202108220fb664ce4c5ac313a564ab5babb153516a3ac0ad8a681/types_pyyaml-6.0.12.20261030-py3-none-any.whl", hash = "sha256:508cc80546918201026a955356222111224e1f4f7deab0b91f2a95933f764e67", size = 21283, upload-time = "2026-10-30T07:50:18.389Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1095,12 +1104,13 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.3.0" +version = "0.3.1" source = { editable = "." } dependencies = [ { name = "anyio" }, { name = "httpx" }, { name = "pydantic-function-models" }, + { name = "pyyaml" }, { name = "sqlparse" }, { name = "tenacity" }, { name = "truststore" }, @@ -1120,6 +1130,7 @@ dev = [ { name = "pytest-trio" }, { name = "ruff" }, { name = "rust-just" }, + { name = "types-pyyaml" }, ] [package.metadata] @@ -1127,6 +1138,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.0.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic-function-models", specifier = ">=0.1.11" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "sqlparse", specifier = ">=0.6.0,<0.7.0" }, { name = "tenacity", specifier = ">=9.0.0" }, { name = "truststore", specifier = ">=0.10.1" }, @@ -1146,6 +1158,7 @@ dev = [ { name = "pytest-trio", specifier = ">=0.8.0" }, { name = "ruff", specifier = ">=0.9.4" }, { name = "rust-just", specifier = ">=1.39.0" }, + { name = "types-pyyaml", specifier = ">=6.0" }, ] [[package]] diff --git a/packages/uipath/docs/core/agents.md b/packages/uipath/docs/core/agents.md index add97b8ed..5768957b4 100644 --- a/packages/uipath/docs/core/agents.md +++ b/packages/uipath/docs/core/agents.md @@ -183,6 +183,7 @@ The full set of Orchestrator services is available to agents: - **Buckets** — download and upload files: [Buckets reference](./buckets.md) - **Connections** — Integration Service connections for ERP and SaaS: [Connections reference](./connections.md) - **Context Grounding** — semantic search over enterprise data: [Context Grounding reference](./context_grounding.md) +- **Skills** — skill bundles bound to the agent, resolved at run start and read on demand: [Skills reference](./skills.md) --- diff --git a/packages/uipath/docs/core/environment_variables.md b/packages/uipath/docs/core/environment_variables.md index bcf1bcc8b..9029e9502 100644 --- a/packages/uipath/docs/core/environment_variables.md +++ b/packages/uipath/docs/core/environment_variables.md @@ -49,6 +49,16 @@ To obtain the folder path, right-click on the folder in UiPath Orchestrator and /> +### Skill Bundles +Coded agents that load [skill bundles](./skills.md) choose where the bundles come from with these variables. None is required: without them an agent reads bundles from `./skills` in the current directory. + +| Variable | Values | Default | Description | +|----------|--------|---------|-------------| +| `UIPATH_SKILLS_SOURCE` | `local`, `orchestrator` | `local` (until the Skills API ships) | `local` copies bundles from a folder; `orchestrator` is the Orchestrator Skills API, a placeholder that skips every bundle until the API ships. An unknown value falls back to `local` with a warning. | +| `UIPATH_SKILLS_LOCAL_ROOT` | path | `/skills` | The folder the `local` source reads, laid out as `//SKILL.md` with an optional `/bundle.json`. | + +A `SkillBundleRef` without a `folder_path` is looked up in the job's folder, which `UIPATH_FOLDER_PATH` or `UIPATH_FOLDER_KEY` supplies (see [Folder Configuration](#folder-configuration)). + ### Telemetry To help us improve the developer experience, the CLI collects basic usage data about command invocations. For more details about UiPath's privacy practices, please review the [privacy statement](https://www.uipath.com/legal/privacy-policy). diff --git a/packages/uipath/docs/core/skills.md b/packages/uipath/docs/core/skills.md new file mode 100644 index 000000000..aa299e681 --- /dev/null +++ b/packages/uipath/docs/core/skills.md @@ -0,0 +1,231 @@ +# Skills + +A skill bundle is a versioned group of skills. Each skill is a folder with a `SKILL.md` whose YAML frontmatter has a `name` and a `description`, plus any reference files or scripts the skill needs. Bundles are authored and imported into an Orchestrator folder with the `uip` CLI (UiPath's coding-agent CLI, separate from the `uipath` Python CLI used on this page) and bound to a coded agent like any other resource. + + +`sdk.skills` resolves the bundles an agent declares, downloads them at run start and exposes their skills to the agent's code: the skill descriptions go into the system prompt, and the full `SKILL.md` is read on demand. + +!!! warning "Preview Feature" + The Orchestrator Skills API is not available yet. Until it ships, `UIPATH_SKILLS_SOURCE` defaults to `local` and bundles are read from `./skills` in the project; see [Local development](#local-development). `orchestrator` is a placeholder today: it skips every bundle as `source_unavailable`. It becomes the default once the API is live. + +--- + +## Declare and Load Bundles + +Call `load_all_async` once when the job starts: in your graph factory or your first node, never at import time. + +```python +from uipath.platform import UiPath +from uipath.platform.skills import SkillBundleRef + +BASE_PROMPT = "You triage supplier invoices." + + +async def make_graph(): + sdk = UiPath() + skills = await sdk.skills.load_all_async([ + SkillBundleRef(name="finance", folder_path="Shared/Skills", version_mode="pinned", version="3"), + SkillBundleRef(name="ops", folder_path="Shared/Skills", version_mode="latest"), + ]) + system_prompt = BASE_PROMPT + "\n\n" + skills.prompt_section() + + def load_skill(name: str) -> str: + """Load a skill's full instructions. `name` is `/` from the Skills section.""" + return skills.read(name) # invoked_by defaults to "model" + + # Bind load_skill to the model as a tool and use system_prompt as the system + # message; the framework-specific wiring is up to you. + ... +``` + +- `version_mode="pinned"` fixes a version; `version` is then required. `version_mode="latest"` takes the newest published version at each call and ignores `version`. +- `folder_path` is optional. Without it the bundle is looked up in the job's folder, taken from `UIPATH_FOLDER_PATH` or `UIPATH_FOLDER_KEY`. +- A bundle that cannot be resolved never fails the run. It is reported in `skills.failures` (a `BundleFailure` with the `ref`, a `reason` and a `detail`) and as an event on the `Skill bundles resolved` trace span, and the agent continues with the bundles that did resolve. +- `skills.names()` lists the resolved skills as `/`. Names are qualified because two bundles may contain a skill with the same name; both stay available. +- `skills.bundles[i].version` is the resolved version; `prompt_section()` also shows it as `(bundle vN)`. +- `skills.prompt_section()` is a `## Skills` markdown section with one line per skill and its description, or an empty string when no bundle has a skill. +- `skills.read(name, path="SKILL.md")` returns a skill file as text. `path` is any file the skill lists (`Skill.files`), relative to the skill folder. Reading a `SKILL.md` records a `Skill load` span; pass `invoked_by="user"` when your own code reads it rather than a tool call made by the model. + +### Files on Disk + +| `destination` | Where bundles land | Who cleans up | +|---|---|---| +| omitted | a temp folder the set owns | you: call `skills.cleanup()` when the run ends | +| `destination=path` | `path`, **emptied first on every call** | the set leaves the folder in place; `cleanup()` only clears `root` | + +`root` is laid out as `///...` next to `.uipath-skills-manifest.json`. Pass a `destination` you do not mind losing. `root` is `None` when no bundle resolved and after `cleanup()`; after `cleanup()` a `read()` of a listed skill raises `RuntimeError` (with no bundle resolved there is nothing to read and `read()` raises `KeyError`). + +--- + +## Local Development + +Keep a copy of the bundles in the project, one folder per bundle: + +``` +skills/ +├── finance/ +│ ├── bundle.json # optional: {"version": "3"} +│ ├── invoice-triage/ +│ │ ├── SKILL.md +│ │ └── reference.md +│ └── vendor-lookup/ +│ └── SKILL.md +└── ops/ + └── runbook/ + └── SKILL.md +``` + +Set the source in `.env` (or in the shell) and run the agent as usual: + +```bash +# .env +UIPATH_SKILLS_SOURCE=local +``` + +```shell +uipath run agent '{"message": "triage invoice 4711"}' +``` + +`UIPATH_SKILLS_SOURCE` selects where bundles come from: + +| Value | Behaviour | +|-------|-----------| +| `local` | Copies `./skills//` (or `UIPATH_SKILLS_LOCAL_ROOT`); a folder holds one version, the one in `bundle.json` or `local` without it. | +| `orchestrator` | The Orchestrator Skills API. A placeholder until the API ships: every bundle is skipped as `source_unavailable` and the agent runs without skills. | + +Today the default is `local`; `orchestrator` becomes the default once the Skills API ships. An unknown value falls back to `local` with a warning. A `pinned` reference to a version other than the folder's is loaded anyway, with a warning. The canonical description of the two variables is in [Environment Variables](./environment_variables.md#skill-bundles). + +--- + +## Bindings + +`uipath init` writes an empty `bindings.json`. Add one entry per `SkillBundleRef` yourself, or let your coding agent derive it from the code; this is the shape: + +```json +{ + "resource": "skillBundle", + "key": "finance.Shared/Skills", + "value": { + "name": { "defaultValue": "finance", "isExpression": false, "displayName": "Name" }, + "folderPath": { "defaultValue": "Shared/Skills", "isExpression": false, "displayName": "Folder Path" }, + "versionMode": { "defaultValue": "pinned", "isExpression": false, "displayName": "Version Mode" }, + "version": { "defaultValue": "3", "isExpression": false, "displayName": "Version" } + }, + "metadata": { "ActivityName": "load_async", "BindingsVersion": "2.2", "DisplayLabel": "FullName" } +} +``` + +`ActivityName` names the SDK method that declares the reference; `load_async` covers both `load_async` and `load_all_async`. + +`uipath pack` ships the file as `content/bindings_v2.json`. `uipath push` does not import skill-bundle bindings yet and reports each one as skipped, so the bundle must exist in Orchestrator before the solution is deployed. + +--- + +## Overrides per Environment + +At deploy time the binding can be overridden per environment. The job receives the override in `uipath.json`, and `load_all_async` applies it before fetching; no code change is needed: + +```json +{ "runtime": { "internalArguments": { "resourceOverwrites": { + "skillBundle.finance": { "name": "finance", "folderPath": "Prod/Skills", "versionMode": "pinned", "version": "7" } +} } } } +``` + +- An override replaces the whole binding, as for the other bindings: it must give `name`, `folderPath` and `versionMode`, plus `version` when `versionMode` is `pinned`. Nothing is taken from the declared binding. +- A key qualified with the design-time folder, `skillBundle.finance.Shared/Skills`, wins over the bare key `skillBundle.finance`. +- An override's `name` only changes which bundle is fetched. The agent still sees the bundle as `finance`: in `names()`, in the prompt section and on disk. +- An incomplete or invalid override (for instance `pinned` without a `version`) is skipped with a warning, and the declared binding is used. + +Locally, put the same object into `__uipath/uipath.json` (the file `uipath run` reads runtime arguments from; create it if it does not exist) and run `uipath run` to test an override. + +--- + +## Traces + +Two spans describe skills in a job trace: + +- `Skill bundles resolved`: one per `load_all_async` call (and per `load_async` call); none is emitted when no bundle is declared. It carries the source, the bundle and skill counts and the failures. Each resolved bundle is an event with its name, folder, version, version mode and skill count; each failure is a `skill.bundle.failed` event with its reason and detail. +- `Skill load - /`: one per `read()` of a `SKILL.md`, with the bundle version, who asked for it (`skill.invoked_by`) and the size of the text. + +Locally, `uipath run agent '{...}' --trace-file trace.jsonl` writes the spans to a JSON Lines file. See [Tracing](./traced.md). + +--- + +## Limits + +| Limit | Value | +|-------|-------| +| File size | 512 KiB per file | +| Bundle size | 24 MiB per bundle | +| Time budget | 20 s per bundle; after the first timeout the remaining bundles are skipped | + +- A file over the size limit is skipped with a warning. A skill is skipped with a warning when its `SKILL.md` is over the size limit or not valid UTF-8. +- Hidden files and folders are skipped, and a folder without a `SKILL.md` is not a skill. The file name is matched in any case: a `skill.md` is accepted and written to the bundle folder as `SKILL.md`. +- A second reference to the same bundle name is reported as `invalid`; the first one is kept. +- Scripts inside a skill are copied with the rest of its files but never executed by the SDK. + +--- + +## Other Calls + +### One Bundle + +```python +one = await sdk.skills.load_async( + "finance", folder_path="Shared/Skills", version_mode="pinned", version="3" +) +``` + +`load_async` returns a `SkillSet` holding exactly one bundle. Unlike `load_all_async`, it raises when the bundle cannot be resolved: `SkillBundleLoadError`, whose `failure.reason` is one of `not_found`, `version_not_found`, `forbidden`, `invalid`, `too_large` or `source_unavailable`. An unknown `version_mode`, or `pinned` without a `version`, raises `ValueError` before anything is fetched. + +### Synchronous Use + +`load_all` and `load` are the synchronous twins of `load_all_async` and `load_async`. Prefer the `*_async` methods inside an agent. A synchronous method runs its async twin to completion; when an event loop is already running in the calling thread it runs on a worker thread and blocks the caller for up to 20 s per bundle. + +--- + +## Reference + +`SkillsService` is what `sdk.skills` returns. `SkillSet` is what `load_all_async` gives back; `SkillBundle`, `Skill` and `BundleFailure` are its parts; `SkillBundleLoadError` is what `load_async` raises. + +::: uipath.platform.skills.SkillsService + options: + show_root_heading: true + show_root_toc_entry: true + heading_level: 3 + +::: uipath.platform.skills.SkillBundleRef + options: + show_root_heading: true + show_root_toc_entry: true + heading_level: 3 + +::: uipath.platform.skills.SkillSet + options: + show_root_heading: true + show_root_toc_entry: true + heading_level: 3 + +::: uipath.platform.skills.SkillBundle + options: + show_root_heading: true + show_root_toc_entry: true + heading_level: 3 + +::: uipath.platform.skills.Skill + options: + show_root_heading: true + show_root_toc_entry: true + heading_level: 3 + +::: uipath.platform.skills.BundleFailure + options: + show_root_heading: true + show_root_toc_entry: true + heading_level: 3 + +::: uipath.platform.skills.SkillBundleLoadError + options: + show_root_heading: true + show_root_toc_entry: true + heading_level: 3 diff --git a/packages/uipath/mkdocs.yml b/packages/uipath/mkdocs.yml index f4572c0c5..07704b947 100644 --- a/packages/uipath/mkdocs.yml +++ b/packages/uipath/mkdocs.yml @@ -78,6 +78,7 @@ nav: - Queues: core/queues.md - Processes: core/processes.md - Resource Catalog: core/resource_catalog.md + - Skills: core/skills.md - Tasks: core/tasks.md - Models: - Business Rules: core/business_rules_models.md diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 03171b551..6741370f0 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.15.0" +version = "2.15.1" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.33, <0.6.0", "uipath-runtime>=0.13.5, <0.14.0", - "uipath-platform>=0.3.0, <0.4.0", + "uipath-platform>=0.3.1, <0.4.0", "uipath-ipc>=2.5.1, <2.6.0", "click>=8.3.3, <9.0.0", "httpx>=0.28.1", diff --git a/packages/uipath/src/uipath/_cli/_utils/_common.py b/packages/uipath/src/uipath/_cli/_utils/_common.py index e5dfa7e66..12a0fda73 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_common.py +++ b/packages/uipath/src/uipath/_cli/_utils/_common.py @@ -7,6 +7,7 @@ from urllib.parse import urlparse import click +from pydantic import ValidationError from uipath.platform.common import ( ResourceOverwrite, @@ -199,6 +200,20 @@ async def may_override_files( ) +def describe_overwrite_error(error: Exception) -> str: + """Describe a failed ``ResourceOverwriteParser.parse`` in one line. + + A pydantic ``ValidationError`` against the overwrite union stringifies to a + multi-line dump of every union member; its first error message is what + actually went wrong (usually an unknown kind). + """ + if isinstance(error, ValidationError): + errors = error.errors() + if errors: + return errors[0]["msg"] + return f"{type(error).__name__}: {error}" + + async def read_resource_overwrites_from_file( directory_path: str | None = None, ) -> dict[str, ResourceOverwrite]: @@ -234,7 +249,7 @@ async def read_resource_overwrites_from_file( logger.warning( "Skipping unrecognized resource overwrite '%s': %s", key, - e, + describe_overwrite_error(e), ) # Return empty dict if file doesn't exist or invalid json diff --git a/packages/uipath/src/uipath/_cli/_utils/_studio_project.py b/packages/uipath/src/uipath/_cli/_utils/_studio_project.py index 84ad03284..0dad77b02 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_studio_project.py +++ b/packages/uipath/src/uipath/_cli/_utils/_studio_project.py @@ -608,9 +608,15 @@ async def _get_existing_resources(self) -> List[dict[str, Any]]: async def get_resource_overwrites(self) -> dict[str, ResourceOverwrite]: """Get resource overwrites from the solution. + Entries whose kind this SDK cannot parse are skipped with a warning, so + one unknown overwrite does not fail a debug or eval run. + Returns: dict[str, ResourceOverwrite]: Dict of resource overwrites """ + # ``_common`` imports this module, so import lazily to avoid the cycle. + from ._common import describe_overwrite_error + if not os.path.exists(UiPathConfig.bindings_file_path): logger.warning( "Bindings file not found at %s, no overwrites to fetch from Studio", @@ -657,10 +663,16 @@ async def get_resource_overwrites(self) -> dict[str, ResourceOverwrite]: json.dumps(data, indent=2), ) - overwrites = {} + overwrites: dict[str, ResourceOverwrite] = {} for key, value in data.items(): - overwrites[key] = ResourceOverwriteParser.parse(key, value) - + try: + overwrites[key] = ResourceOverwriteParser.parse(key, value) + except Exception as e: # one unknown kind must not fail a debug or eval run + logger.warning( + "Skipping unrecognized resource overwrite '%s' from Studio: %s", + key, + describe_overwrite_error(e), + ) return overwrites async def create_virtual_resource( diff --git a/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md b/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md index f433f34db..b53ccfdc2 100644 --- a/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md +++ b/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md @@ -969,6 +969,28 @@ sdk.semantic_proxy.detect_pii_async(request: uipath.platform.semantic_proxy.sema ``` +### Skills + +Skills service + +```python +# Close the HTTP clients owned by this service. +sdk.skills.aclose() -> None + +# Resolve and download one bundle; see ``load_async``. +sdk.skills.load(name: str, folder_path: str | None=None, version_mode: str | VersionMode="latest", version: str | None=None, destination: Path | None=None) -> SkillSet + +# Resolve and download ``refs``; see ``load_all_async``. +sdk.skills.load_all(refs: Sequence[SkillBundleRef], destination: Path | None=None, source: SkillBundleSource | None=None) -> SkillSet + +# Resolve ``refs`` in order, write each bundle under ``//`` and describe the set. +sdk.skills.load_all_async(refs: Sequence[SkillBundleRef], destination: Path | None=None, source: SkillBundleSource | None=None) -> SkillSet + +# Resolve and download one bundle, raising when it cannot be. +sdk.skills.load_async(name: str, folder_path: str | None=None, version_mode: str | VersionMode="latest", version: str | None=None, destination: Path | None=None) -> SkillSet + +``` + ### Tasks Tasks service diff --git a/packages/uipath/tests/cli/test_studio_project_overwrites.py b/packages/uipath/tests/cli/test_studio_project_overwrites.py new file mode 100644 index 000000000..0e49698af --- /dev/null +++ b/packages/uipath/tests/cli/test_studio_project_overwrites.py @@ -0,0 +1,91 @@ +"""Per-entry behaviour of ``StudioClient.get_resource_overwrites``. + +``uipath debug`` and ``uipath eval`` load their resource overwrites from the +Studio backend when ``UIPATH_PROJECT_ID`` is set. One entry of a kind this SDK +does not know must not fail the whole run. +""" + +import json +import logging +from pathlib import Path + +import pytest +from pytest_httpx import HTTPXMock + +from uipath._cli._utils._studio_project import StudioClient +from uipath.platform.common import GenericResourceOverwrite, UiPathConfig + +_ORG_URL = "https://cloud.uipath.com/organization" +_PROJECT_ID = "test-project-id" +_SOLUTION_ID = "test-solution-id" +_OVERWRITES_URL = ( + f"{_ORG_URL}/studio_/backend/api/resourcebuilder/{_SOLUTION_ID}/binding-overwrites" +) +_LOGGER_NAME = "uipath._cli._utils._studio_project" + + +@pytest.fixture +def studio_logs( + caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> pytest.LogCaptureFixture: + """Records reach caplog's root handler whatever state CLI tests left the loggers in. + + Tests that invoke the Click CLI leave the ``uipath`` loggers, the module + logger included, with ``propagate = False``. Re-enable propagation along + the whole chain for this test only; the handler stays on root, so each + record is captured exactly once. + """ + logger: logging.Logger | None = logging.getLogger(_LOGGER_NAME) + while logger is not None: + monkeypatch.setattr(logger, "propagate", True) + logger = logger.parent + caplog.set_level(logging.WARNING, logger=_LOGGER_NAME) + return caplog + + +@pytest.fixture +def studio_client( + mock_env_vars: dict[str, str], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> StudioClient: + """A client over a real ``UiPath`` whose solution id is already cached. + + ``UiPathConfig.bindings_file_path`` is relative to the working directory, + so the test runs from ``tmp_path`` with a ``bindings.json`` in it. + """ + monkeypatch.chdir(tmp_path) + (tmp_path / "bindings.json").write_text( + json.dumps({"version": "2", "resources": []}) + ) + monkeypatch.setattr(UiPathConfig, "studio_solution_id", _SOLUTION_ID) + return StudioClient(project_id=_PROJECT_ID) + + +async def test_get_resource_overwrites_skips_unknown_entries( + studio_client: StudioClient, + httpx_mock: HTTPXMock, + studio_logs: pytest.LogCaptureFixture, +) -> None: + httpx_mock.add_response( + method="POST", + url=_OVERWRITES_URL, + json={ + "asset.A": {"name": "B", "folderPath": "F"}, + "weird.X": {"foo": 1}, + }, + ) + + overwrites = await studio_client.get_resource_overwrites() + + assert set(overwrites) == {"asset.A"} + asset = overwrites["asset.A"] + assert isinstance(asset, GenericResourceOverwrite) + assert asset.resource_identifier == "B" + assert asset.folder_identifier == "F" + warnings = [ + r.getMessage() for r in studio_logs.records if r.levelno == logging.WARNING + ] + skipped = [message for message in warnings if "weird.X" in message] + assert len(skipped) == 1, warnings + assert "\n" not in skipped[0], skipped[0] diff --git a/packages/uipath/tests/resource_overrides/test_resource_overrides.py b/packages/uipath/tests/resource_overrides/test_resource_overrides.py index 8d39a762d..86eb29fc7 100644 --- a/packages/uipath/tests/resource_overrides/test_resource_overrides.py +++ b/packages/uipath/tests/resource_overrides/test_resource_overrides.py @@ -1,5 +1,6 @@ # type: ignore import json +import logging import os from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -11,6 +12,7 @@ from pytest_httpx import HTTPXMock from uipath._cli import cli +from uipath._cli._utils._common import read_resource_overwrites_from_file from uipath.platform.common import ResourceOverwriteParser from uipath.runtime import UiPathRuntimeResult @@ -564,3 +566,47 @@ async def get_overwrites(): assert input_attrs_dict["folder_path"] == "overridden_folder" assert output_attrs_dict["resource_name"] == "overridden_resource" assert output_attrs_dict["folder"] == "overridden_folder" + + +class TestReadResourceOverwritesFromFile: + """``uipath run`` reads its overwrites from ``uipath.json`` entry by entry.""" + + @pytest.fixture + def common_logs(self, caplog, monkeypatch): + # CLI tests earlier in the session leave the ``uipath`` loggers, the + # module logger included, with propagate=False. Re-enable propagation + # along the whole chain for this test only; the handler stays on root, + # so each record is captured exactly once. + logger = logging.getLogger("uipath._cli._utils._common") + while logger is not None: + monkeypatch.setattr(logger, "propagate", True) + logger = logger.parent + caplog.set_level(logging.WARNING, logger="uipath._cli._utils._common") + return caplog + + @staticmethod + def _write_uipath_json(directory: Path, overwrites: dict) -> None: + config = {"runtime": {"internalArguments": {"resourceOverwrites": overwrites}}} + (directory / "uipath.json").write_text(json.dumps(config)) + + async def test_unknown_overwrite_kind_is_skipped_with_a_warning( + self, tmp_path, common_logs + ): + self._write_uipath_json( + tmp_path, + { + "asset.A": {"name": "B", "folderPath": "F"}, + "weird.X": {"foo": 1}, + }, + ) + + overwrites = await read_resource_overwrites_from_file(str(tmp_path)) + + assert set(overwrites) == {"asset.A"} + assert overwrites["asset.A"].resource_identifier == "B" + warnings = [ + r.getMessage() for r in common_logs.records if r.levelno == logging.WARNING + ] + skipped = [message for message in warnings if "weird.X" in message] + assert len(skipped) == 1, warnings + assert "\n" not in skipped[0], skipped[0] diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 35b84ec80..2fa9fc106 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.15.0" +version = "2.15.1" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2762,12 +2762,13 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.3.0" +version = "0.3.1" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, { name = "httpx" }, { name = "pydantic-function-models" }, + { name = "pyyaml" }, { name = "sqlparse" }, { name = "tenacity" }, { name = "truststore" }, @@ -2779,6 +2780,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.0.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic-function-models", specifier = ">=0.1.11" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "sqlparse", specifier = ">=0.6.0,<0.7.0" }, { name = "tenacity", specifier = ">=9.0.0" }, { name = "truststore", specifier = ">=0.10.1" }, @@ -2798,6 +2800,7 @@ dev = [ { name = "pytest-trio", specifier = ">=0.8.0" }, { name = "ruff", specifier = ">=0.9.4" }, { name = "rust-just", specifier = ">=1.39.0" }, + { name = "types-pyyaml", specifier = ">=6.0" }, ] [[package]]