From 528a307ae6e727667dd4eb8b84cac33f93a91e29 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 | 29 ++-- src/uipath_langchain/agent/advanced/utils.py | 8 + .../agent/tools/internal_tools/__init__.py | 6 +- .../tools/internal_tools/uipath_cli_tool.py | 163 ++++++++++++++++++ tests/agent/advanced/test_skills_injection.py | 80 +++++++++ .../internal_tools/test_uipath_cli_tool.py | 102 +++++++++++ 7 files changed, 381 insertions(+), 11 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..1604566e6 100644 --- a/src/uipath_langchain/agent/advanced/agent.py +++ b/src/uipath_langchain/agent/advanced/agent.py @@ -22,6 +22,7 @@ from .types import AdvancedAgentGraphState, ConversationalAdvancedAgentGraphState from .utils import ( MEMORY_INDEX_VIRTUAL_PATH, + SKILLS_VIRTUAL_PATH, create_state_with_input, resolve_input_attachments, ) @@ -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,18 +66,22 @@ 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 [] - ) + is_filesystem = isinstance(backend, FilesystemBackend) + memory_sources = [MEMORY_INDEX_VIRTUAL_PATH] if is_filesystem else [] + skills_sources: list[str] = [] + if skills: + skills_sources = list(skills) + if is_filesystem and SKILLS_VIRTUAL_PATH not in skills_sources: + skills_sources.insert(0, SKILLS_VIRTUAL_PATH) inner_graph = create_advanced_agent( model=model, @@ -82,6 +90,7 @@ def create_advanced_agent_graph( backend=backend, response_format=response_format, memory=memory_sources, + skills=skills_sources, ) 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..96c6ddbe4 100644 --- a/src/uipath_langchain/agent/advanced/utils.py +++ b/src/uipath_langchain/agent/advanced/utils.py @@ -30,6 +30,14 @@ # FilesystemBackend resolves it under the workspace root. MEMORY_INDEX_VIRTUAL_PATH = f"/{MEMORY_DIR_NAME}/{MEMORY_INDEX_FILENAME}" +# Skills live under /skills/: each is a directory with a SKILL.md, +# loaded by deepagents' SkillsMiddleware with progressive disclosure. +SKILLS_DIR_NAME = "skills" + +# Virtual source path handed to SkillsMiddleware; the virtual-mode +# FilesystemBackend resolves it under the workspace root. +SKILLS_VIRTUAL_PATH = f"/{SKILLS_DIR_NAME}/" + 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..8c43d4ba1 --- /dev/null +++ b/src/uipath_langchain/agent/tools/internal_tools/uipath_cli_tool.py @@ -0,0 +1,163 @@ +"""Workspace CLI tool injected when skills are active. + +``uipath_cli`` runs ``uipath`` / ``uip`` commands a skill prescribes, jailed to +the agent's workspace. +""" + +from __future__ import annotations + +import os +import shlex +import subprocess +from collections.abc import Iterable +from pathlib import Path +from typing import Annotated + +from langchain_core.tools import BaseTool, tool + +ALLOWED_BINARIES = {"uipath", "uip"} +MAX_LOG_BYTES = 4000 + +# 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", +] + + +def create_uipath_cli_tool( + workspace: Path | str, + timeout_sec: int = 120, + denied_subcommands: Iterable[str] | None = None, +) -> BaseTool: + """Build a CLI tool bound to ``workspace``. + + Pass the resulting tool via the ``tools`` argument of the advanced agent + builders — inject it only for UiPath skills, not customer skills. + + Args: + workspace: Root for CLI invocations, created if absent. Commands cannot + escape it; subdirectories are created on demand. + timeout_sec: Per-command subprocess timeout. Default 120s. + denied_subcommands: Blocked verbs; a command is rejected when a non-flag + token equals one (case-insensitive) or starts with ``-``. + Defaults to :data:`DEFAULT_DENIED_SUBCOMMANDS`; pass an empty + iterable to allow everything. + """ + workspace_path = Path(workspace).expanduser().resolve() + workspace_path.mkdir(parents=True, exist_ok=True) + denied = frozenset( + s.lower() + for s in ( + DEFAULT_DENIED_SUBCOMMANDS + if denied_subcommands is None + else denied_subcommands + ) + ) + + @tool + def uipath_cli( + command: Annotated[ + str, + "Full CLI command starting with `uipath` or `uip`, e.g. 'uipath pack' or 'uip solution publish dev'.", + ], + subdir: Annotated[ + str, + "Workspace-relative subdirectory to run in. Give each scaffolded project its own subdir. Defaults to the workspace root.", + ] = "", + ) -> str: + """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. + """ + run_dir = workspace_path + if subdir: + candidate = (workspace_path / subdir).resolve() + if not candidate.is_relative_to(workspace_path): + return f"ERROR: subdir '{subdir}' escapes the workspace directory." + candidate.mkdir(parents=True, exist_ok=True) + run_dir = candidate + + try: + argv = shlex.split(command) + except ValueError as e: + return f"ERROR: could not parse command: {e}" + + 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)}" + ) + + def is_denied(tok: str) -> bool: + low = tok.lower() + return any(low == d or low.startswith(f"{d}-") for d in denied) + + blocked = next( + (tok for tok in argv[1:] if not tok.startswith("-") and is_denied(tok)), + None, + ) + if blocked is not None: + return ( + f"ERROR: subcommand '{blocked}' is not allowed. These destructive " + f"subcommands are blocked: {sorted(denied)}" + ) + + try: + proc = subprocess.run( + argv, + cwd=str(run_dir), + capture_output=True, + text=True, + timeout=timeout_sec, + check=False, + ) + except subprocess.TimeoutExpired: + return f"ERROR: command timed out after {timeout_sec}s" + except FileNotFoundError: + return f"ERROR: '{binary}' 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]}" + ) + + # Classify as "internal" so traces label it (untyped default is "Integration"). + uipath_cli.metadata = {"tool_type": "internal", "display_name": "uipath_cli"} + return uipath_cli 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..bfe52e187 --- /dev/null +++ b/tests/agent/tools/internal_tools/test_uipath_cli_tool.py @@ -0,0 +1,102 @@ +"""Tests for the sandboxed ``uipath_cli`` built-in tool.""" + +from pathlib import Path +from typing import Any +from unittest.mock import patch + +from uipath_langchain.agent.tools.internal_tools.uipath_cli_tool import ( + create_uipath_cli_tool, +) + + +def _invoke(tool: Any, **kwargs: Any) -> str: + return tool.invoke(kwargs) + + +class TestCreateUipathCliTool: + def test_creates_workspace_when_absent(self, tmp_path: Path) -> None: + target = tmp_path / "does" / "not" / "exist" + create_uipath_cli_tool(target) + assert target.is_dir() + + def test_marks_tool_as_internal(self, tmp_path: Path) -> None: + tool = create_uipath_cli_tool(tmp_path) + assert tool.metadata == {"tool_type": "internal", "display_name": "uipath_cli"} + + def test_rejects_disallowed_binary(self, tmp_path: Path) -> None: + tool = create_uipath_cli_tool(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(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(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(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(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(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(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(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(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