From 85bf05e119b4ba19eb0d7e6fe1bff099860edca0 Mon Sep 17 00:00:00 2001 From: Maria Dhakal Date: Wed, 8 Jul 2026 11:55:10 -0700 Subject: [PATCH] feat: add skills feature to advanced agent with workspace to load skills Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agent/advanced/__init__.py | 4 + src/uipath_langchain/agent/advanced/agent.py | 19 +- src/uipath_langchain/agent/advanced/utils.py | 23 ++ .../agent/tools/internal_tools/__init__.py | 6 +- .../tools/internal_tools/uipath_cli_tool.py | 210 ++++++++++++++++++ tests/agent/advanced/test_skills_injection.py | 80 +++++++ .../internal_tools/test_uipath_cli_tool.py | 113 ++++++++++ 7 files changed, 447 insertions(+), 8 deletions(-) create mode 100644 src/uipath_langchain/agent/tools/internal_tools/uipath_cli_tool.py create mode 100644 tests/agent/advanced/test_skills_injection.py create mode 100644 tests/agent/tools/internal_tools/test_uipath_cli_tool.py diff --git a/src/uipath_langchain/agent/advanced/__init__.py b/src/uipath_langchain/agent/advanced/__init__.py index 1605996aa..0ed121bc2 100644 --- a/src/uipath_langchain/agent/advanced/__init__.py +++ b/src/uipath_langchain/agent/advanced/__init__.py @@ -14,6 +14,8 @@ MEMORY_DIR_NAME, MEMORY_INDEX_FILENAME, MEMORY_INDEX_VIRTUAL_PATH, + SKILLS_DIR_NAME, + SKILLS_VIRTUAL_PATH, create_state_with_input, ) @@ -21,6 +23,8 @@ "MEMORY_DIR_NAME", "MEMORY_INDEX_FILENAME", "MEMORY_INDEX_VIRTUAL_PATH", + "SKILLS_DIR_NAME", + "SKILLS_VIRTUAL_PATH", "AdvancedAgentGraphState", "BackendFactory", "BackendProtocol", diff --git a/src/uipath_langchain/agent/advanced/agent.py b/src/uipath_langchain/agent/advanced/agent.py index 4baaf1f0b..318d3e106 100644 --- a/src/uipath_langchain/agent/advanced/agent.py +++ b/src/uipath_langchain/agent/advanced/agent.py @@ -24,6 +24,7 @@ MEMORY_INDEX_VIRTUAL_PATH, create_state_with_input, resolve_input_attachments, + resolve_skill_sources, ) @@ -35,12 +36,14 @@ def create_advanced_agent( backend: BackendProtocol | BackendFactory | None = None, response_format: ResponseFormat[Any] | None = None, memory: Sequence[str] = (), + skills: Sequence[str] = (), ) -> CompiledStateGraph[Any, Any, Any, Any]: """Create a deepagents agent with planning, filesystem, and sub-agent tools. - ``memory`` is a list of file paths loaded via deepagents' ``MemoryMiddleware``: - each is read from ``backend`` and injected into the system prompt every turn, - and the model maintains them with ``edit_file``. Empty disables the middleware. + ``memory`` (file paths) and ``skills`` (source directories) are loaded via + deepagents' ``MemoryMiddleware`` and ``SkillsMiddleware``; an empty sequence + disables each. Workspace tools like ``uipath_cli`` are supplied by the caller + via ``tools``. """ return _create_deep_agent( model=model, @@ -50,6 +53,7 @@ def create_advanced_agent( backend=backend, response_format=response_format, memory=list(memory) or None, + skills=list(skills) or None, ) @@ -62,14 +66,14 @@ def create_advanced_agent_graph( input_schema: type[BaseModel] | None, output_schema: type[BaseModel], build_user_message: Callable[[dict[str, Any]], str], + skills: Sequence[str] | None = None, ) -> StateGraph[Any, Any, Any, Any]: """Wrap the advanced agent in a parent graph that maps typed I/O to/from messages. With a ``FilesystemBackend``, attachment-shaped inputs are downloaded into the - workspace and given a ``FilePath`` before the user message is built. A - ``FilesystemBackend`` also enables workspace memory: deepagents' - ``MemoryMiddleware`` reads ``/memory/MEMORY.md`` from the backend each turn. - Memory stays disabled for non-filesystem backends, which carry no workspace. + workspace, memory (``/memory/MEMORY.md``) is enabled, and the workspace + ``skills/`` dir is prepended to any declared ``skills``. Both stay disabled for + non-filesystem backends, which carry no workspace. """ memory_sources = ( [MEMORY_INDEX_VIRTUAL_PATH] if isinstance(backend, FilesystemBackend) else [] @@ -82,6 +86,7 @@ def create_advanced_agent_graph( backend=backend, response_format=response_format, memory=memory_sources, + skills=resolve_skill_sources(backend, skills), ) wrapper_state = create_state_with_input(input_schema) diff --git a/src/uipath_langchain/agent/advanced/utils.py b/src/uipath_langchain/agent/advanced/utils.py index 0726bf5d2..267e3e2a7 100644 --- a/src/uipath_langchain/agent/advanced/utils.py +++ b/src/uipath_langchain/agent/advanced/utils.py @@ -4,6 +4,7 @@ import copy import logging import uuid +from collections.abc import Sequence from pathlib import Path from typing import Any, NamedTuple, cast @@ -30,6 +31,28 @@ # FilesystemBackend resolves it under the workspace root. MEMORY_INDEX_VIRTUAL_PATH = f"/{MEMORY_DIR_NAME}/{MEMORY_INDEX_FILENAME}" +# Skills live under /skills/ +SKILLS_DIR_NAME = "skills" + +SKILLS_VIRTUAL_PATH = f"/{SKILLS_DIR_NAME}/" + + +def resolve_skill_sources( + backend: BackendProtocol | BackendFactory | None, + skills: Sequence[str] | None, +) -> list[str]: + """Resolve the skill sources for ``SkillsMiddleware``. + + With a ``FilesystemBackend`` the workspace ``skills/`` dir is prepended to the + declared ``skills``; other backends carry no workspace, so only ``skills`` is used. + """ + if not skills: + return [] + sources = list(skills) + if isinstance(backend, FilesystemBackend) and SKILLS_VIRTUAL_PATH not in sources: + sources.insert(0, SKILLS_VIRTUAL_PATH) + return sources + def create_state_with_input( input_schema: type[BaseModel] | None, diff --git a/src/uipath_langchain/agent/tools/internal_tools/__init__.py b/src/uipath_langchain/agent/tools/internal_tools/__init__.py index 5298b109f..b35216ebd 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/__init__.py +++ b/src/uipath_langchain/agent/tools/internal_tools/__init__.py @@ -1,5 +1,9 @@ """Internal Tool creation and management for LowCode agents.""" from .internal_tool_factory import create_internal_tool +from .uipath_cli_tool import create_uipath_cli_tool -__all__ = ["create_internal_tool"] +__all__ = [ + "create_internal_tool", + "create_uipath_cli_tool", +] diff --git a/src/uipath_langchain/agent/tools/internal_tools/uipath_cli_tool.py b/src/uipath_langchain/agent/tools/internal_tools/uipath_cli_tool.py new file mode 100644 index 000000000..ed33a5fe6 --- /dev/null +++ b/src/uipath_langchain/agent/tools/internal_tools/uipath_cli_tool.py @@ -0,0 +1,210 @@ +"""Workspace CLI tool injected when skills are active.""" + +from __future__ import annotations + +import os +import shlex +import subprocess +from collections.abc import Iterable +from pathlib import Path + +from langchain_core.tools import BaseTool +from pydantic import BaseModel, Field +from uipath.runtime import Workspace + +from uipath_langchain.agent.tools.base_uipath_structured_tool import ( + BaseUiPathStructuredTool, +) + +ALLOWED_BINARIES = {"uipath", "uip"} +MAX_LOG_BYTES = 4000 + +# Classify as "internal" so traces label it (untyped default is "Integration"). +UIPATH_CLI_TOOL_NAME = "uipath_cli" +UIPATH_CLI_TOOL_TYPE = "internal" + +# Destructive verbs blocked by default. +DEFAULT_DENIED_SUBCOMMANDS = frozenset( + { + "delete", + "remove", + "cancel", + "uninstall", + "unassign", + "unlink", + "unset", + "clear", + "rm", + "del", + "destroy", + "purge", + "drop", + "revoke", + "deactivate", + "undeploy", + "unpublish", + "unregister", + "unimport", + "prune", + "wipe", + "reset", + } +) + +__all__ = [ + "create_uipath_cli_tool", + "DEFAULT_DENIED_SUBCOMMANDS", + "UIPATH_CLI_TOOL_NAME", + "UIPATH_CLI_TOOL_TYPE", +] + +_TOOL_DESCRIPTION = ( + "Run a `uipath` / `uip` command in a workspace subdirectory. Returns combined " + "stdout/stderr. Only `uipath` and `uip` are allowed. Inherits the agent process " + "environment, including `UIPATH_ACCESS_TOKEN` / `UIPATH_BASE_URL` for tenant auth." +) + + +class UiPathCliArgs(BaseModel): + """Arguments for ``uipath_cli``.""" + + command: str = Field( + description=( + "Full CLI command starting with `uipath` or `uip`, e.g. 'uipath pack' " + "or 'uip solution publish dev'." + ), + ) + subdir: str = Field( + default="", + description=( + "Workspace-relative subdirectory to run in. Give each scaffolded " + "project its own subdir. Defaults to the workspace root." + ), + ) + + +def _resolve_run_dir(workspace_path: Path, subdir: str) -> Path | str: + """Resolve ``subdir`` under the workspace""" + if not subdir: + return workspace_path + candidate = (workspace_path / subdir).resolve() + if not candidate.is_relative_to(workspace_path): + return f"ERROR: subdir '{subdir}' escapes the workspace directory." + return candidate + + +def _is_denied_token(tok: str, denied: frozenset[str]) -> bool: + low = tok.lower() + return any(low == d or low.startswith(f"{d}-") for d in denied) + + +def _validate_command(argv: list[str], denied: frozenset[str]) -> str | None: + """Return an ERROR string if the command is empty, uses a disallowed binary, or + contains a blocked destructive subcommand; else ``None``.""" + if not argv: + return "ERROR: empty command" + + binary = argv[0] + # Reject any path-qualified binary (e.g. ./uip, /tmp/uip) so only the + # UiPath CLI resolved from PATH runs, not a lookalike in the workspace. + if os.path.basename(binary) != binary or binary not in ALLOWED_BINARIES: + return ( + f"ERROR: '{binary}' is not allowed. Only these binaries are " + f"permitted (by name, without a path): {sorted(ALLOWED_BINARIES)}" + ) + + blocked = next( + ( + tok + for tok in argv[1:] + if not tok.startswith("-") and _is_denied_token(tok, denied) + ), + None, + ) + if blocked is not None: + return ( + f"ERROR: subcommand '{blocked}' is not allowed. These destructive " + f"subcommands are blocked: {sorted(denied)}" + ) + return None + + +def _execute_cli(argv: list[str], run_dir: Path, timeout_sec: int) -> str: + """Run ``argv`` in ``run_dir`` and return combined exit code / stdout / stderr.""" + try: + proc = subprocess.run( + argv, + cwd=str(run_dir), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout_sec, + check=False, + ) + except subprocess.TimeoutExpired: + return f"ERROR: command timed out after {timeout_sec}s" + except FileNotFoundError: + return f"ERROR: '{argv[0]}' not found on PATH. Install the UiPath CLI." + + return ( + f"exit_code: {proc.returncode}\n" + f"--- stdout ---\n{proc.stdout[:MAX_LOG_BYTES]}\n" + f"--- stderr ---\n{proc.stderr[:MAX_LOG_BYTES]}" + ) + + +def _run_uipath_cli( + command: str, + subdir: str, + workspace_path: Path, + denied: frozenset[str], + timeout_sec: int, +) -> str: + """Validate and run a single ``uipath`` / ``uip`` command, jailed to the workspace.""" + run_dir = _resolve_run_dir(workspace_path, subdir) + if isinstance(run_dir, str): # ERROR string + return run_dir + + try: + argv = shlex.split(command) + except ValueError as e: + return f"ERROR: could not parse command: {e}" + + error = _validate_command(argv, denied) + if error is not None: + return error + + return _execute_cli(argv, run_dir, timeout_sec) + + +def create_uipath_cli_tool( + workspace: Workspace, + timeout_sec: int = 120, + denied_subcommands: Iterable[str] | None = None, +) -> BaseTool: + """Build a ``uipath_cli`` tool jailed to ``workspace`` for advanced agents with skills.""" + workspace_path = Path(workspace.path).expanduser().resolve() + denied = frozenset( + s.lower() + for s in ( + DEFAULT_DENIED_SUBCOMMANDS + if denied_subcommands is None + else denied_subcommands + ) + ) + + def run_uipath_cli(command: str, subdir: str = "") -> str: + return _run_uipath_cli(command, subdir, workspace_path, denied, timeout_sec) + + return BaseUiPathStructuredTool( + name=UIPATH_CLI_TOOL_NAME, + description=_TOOL_DESCRIPTION, + args_schema=UiPathCliArgs, + func=run_uipath_cli, + metadata={ + "tool_type": UIPATH_CLI_TOOL_TYPE, + "display_name": UIPATH_CLI_TOOL_NAME, + "args_schema": UiPathCliArgs, + }, + ) diff --git a/tests/agent/advanced/test_skills_injection.py b/tests/agent/advanced/test_skills_injection.py new file mode 100644 index 000000000..dbcfa61d7 --- /dev/null +++ b/tests/agent/advanced/test_skills_injection.py @@ -0,0 +1,80 @@ +"""Tests for deepagents skills based on the backend wrapper.""" + +from typing import Any +from unittest.mock import MagicMock, patch + +from deepagents.backends.filesystem import FilesystemBackend +from langchain_core.language_models import BaseChatModel +from pydantic import BaseModel + +from uipath_langchain.agent.advanced.agent import ( + create_advanced_agent_graph, +) +from uipath_langchain.agent.advanced.utils import ( + SKILLS_VIRTUAL_PATH, +) + + +class _Input(BaseModel): + """Minimal input schema for the test.""" + + task: str + + +class _Output(BaseModel): + """Minimal output schema for the test.""" + + result: str = "" + + +def _build_user_message(args: dict[str, Any]) -> str: + return args.get("task", "") + + +def _skills_kwarg(backend: Any, **overrides: Any) -> Any: + """Build the wrapper graph and return the ``skills`` kwarg handed to deepagents.""" + with patch( + "uipath_langchain.agent.advanced.agent._create_deep_agent", + return_value=MagicMock(), + ) as mock_create: + create_advanced_agent_graph( + model=MagicMock(spec=BaseChatModel), + tools=[], + system_prompt="", + backend=backend, + response_format=None, + input_schema=_Input, + output_schema=_Output, + build_user_message=_build_user_message, + **overrides, + ) + return mock_create.call_args.kwargs["skills"] + + +class TestWorkspaceSkillsWiring: + """Skills are opt-in: enabled only when the caller declares them.""" + + def test_disabled_by_default_on_filesystem_backend(self, tmp_path: Any) -> None: + backend = FilesystemBackend(root_dir=tmp_path, virtual_mode=True) + assert _skills_kwarg(backend) is None + + def test_empty_skills_sequence_disables(self, tmp_path: Any) -> None: + # An empty sequence disables skills, matching the ``memory`` parameter. + backend = FilesystemBackend(root_dir=tmp_path, virtual_mode=True) + assert _skills_kwarg(backend, skills=[]) is None + + def test_declared_skills_enable_and_prepend_workspace(self, tmp_path: Any) -> None: + backend = FilesystemBackend(root_dir=tmp_path, virtual_mode=True) + assert _skills_kwarg(backend, skills=["/extra/"]) == [ + SKILLS_VIRTUAL_PATH, + "/extra/", + ] + + def test_no_duplicate_workspace_source(self, tmp_path: Any) -> None: + backend = FilesystemBackend(root_dir=tmp_path, virtual_mode=True) + assert _skills_kwarg(backend, skills=[SKILLS_VIRTUAL_PATH]) == [ + SKILLS_VIRTUAL_PATH + ] + + def test_explicit_sources_only_for_non_filesystem_backend(self) -> None: + assert _skills_kwarg(None, skills=["/extra/"]) == ["/extra/"] diff --git a/tests/agent/tools/internal_tools/test_uipath_cli_tool.py b/tests/agent/tools/internal_tools/test_uipath_cli_tool.py new file mode 100644 index 000000000..4ad0dd770 --- /dev/null +++ b/tests/agent/tools/internal_tools/test_uipath_cli_tool.py @@ -0,0 +1,113 @@ +"""Tests for the sandboxed ``uipath_cli`` built-in tool.""" + +from pathlib import Path +from typing import Any +from unittest.mock import patch + +from uipath.runtime import Workspace + +from uipath_langchain.agent.tools.internal_tools.uipath_cli_tool import ( + create_uipath_cli_tool, +) + + +def _workspace(path: Path) -> Workspace: + return Workspace(path, cleanup=False) + + +def _invoke(tool: Any, **kwargs: Any) -> str: + return tool.invoke(kwargs) + + +class TestCreateUipathCliTool: + def test_does_not_create_workspace(self, tmp_path: Path) -> None: + # The runtime owns workspace creation; the tool must not create directories. + target = tmp_path / "does" / "not" / "exist" + create_uipath_cli_tool(_workspace(target)) + assert not target.exists() + + def test_marks_tool_as_internal(self, tmp_path: Path) -> None: + tool = create_uipath_cli_tool(_workspace(tmp_path)) + assert tool.metadata is not None + assert tool.metadata["tool_type"] == "internal" + assert tool.metadata["display_name"] == "uipath_cli" + + def test_rejects_disallowed_binary(self, tmp_path: Path) -> None: + tool = create_uipath_cli_tool(_workspace(tmp_path)) + result = _invoke(tool, command="rm -rf /") + assert "not allowed" in result + assert "rm" in result + + def test_rejects_path_qualified_binary(self, tmp_path: Path) -> None: + # A lookalike 'uip' inside the workspace must not be runnable via a path. + tool = create_uipath_cli_tool(_workspace(tmp_path)) + for command in ("./uip login", "/tmp/uip login", "bin/uipath pack"): + result = _invoke(tool, command=command) + assert "not allowed" in result, command + + def test_rejects_subdir_escaping_workspace(self, tmp_path: Path) -> None: + tool = create_uipath_cli_tool(_workspace(tmp_path)) + result = _invoke(tool, command="uipath pack", subdir="../outside") + assert "escapes the workspace" in result + + def test_reports_missing_binary(self, tmp_path: Path) -> None: + tool = create_uipath_cli_tool(_workspace(tmp_path)) + with patch( + "uipath_langchain.agent.tools.internal_tools.uipath_cli_tool.subprocess.run", + side_effect=FileNotFoundError(), + ): + result = _invoke(tool, command="uipath --version") + assert "not found on PATH" in result + + def test_rejects_denied_subcommand_by_default(self, tmp_path: Path) -> None: + tool = create_uipath_cli_tool(_workspace(tmp_path)) + result = _invoke(tool, command="uip solution delete MySolution") + assert "not allowed" in result + assert "delete" in result + + def test_rejects_hyphenated_destructive_variant(self, tmp_path: Path) -> None: + tool = create_uipath_cli_tool(_workspace(tmp_path)) + result = _invoke(tool, command="uip or queue-items delete-bulk") + assert "not allowed" in result + assert "delete-bulk" in result + + def test_custom_denied_subcommands_override_default(self, tmp_path: Path) -> None: + tool = create_uipath_cli_tool( + _workspace(tmp_path), denied_subcommands=["publish"] + ) + # 'delete' is no longer denied once the default set is overridden. + with patch( + "uipath_langchain.agent.tools.internal_tools.uipath_cli_tool.subprocess.run", + side_effect=FileNotFoundError(), + ): + allowed = _invoke(tool, command="uip delete asset X") + assert "not found on PATH" in allowed + blocked = _invoke(tool, command="uip solution publish dev") + assert "not allowed" in blocked + assert "publish" in blocked + + def test_does_not_block_benign_commands(self, tmp_path: Path) -> None: + tool = create_uipath_cli_tool(_workspace(tmp_path)) + with patch( + "uipath_langchain.agent.tools.internal_tools.uipath_cli_tool.subprocess.run", + side_effect=FileNotFoundError(), + ): + for command in ( + "uip solution deploy dev", + "uip solution publish dev", + "uipath pack", + "uip login", + ): + result = _invoke(tool, command=command) + assert "not allowed" not in result, command + + def test_custom_timeout_is_used(self, tmp_path: Path) -> None: + tool = create_uipath_cli_tool(_workspace(tmp_path), timeout_sec=5) + with patch( + "uipath_langchain.agent.tools.internal_tools.uipath_cli_tool.subprocess.run", + ) as mock_run: + mock_run.return_value = type( + "P", (), {"returncode": 0, "stdout": "", "stderr": ""} + )() + _invoke(tool, command="uipath --version") + assert mock_run.call_args.kwargs["timeout"] == 5