Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/uipath_langchain/agent/advanced/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@
MEMORY_DIR_NAME,
MEMORY_INDEX_FILENAME,
MEMORY_INDEX_VIRTUAL_PATH,
SKILLS_DIR_NAME,
SKILLS_VIRTUAL_PATH,
create_state_with_input,
)

__all__ = [
"MEMORY_DIR_NAME",
"MEMORY_INDEX_FILENAME",
"MEMORY_INDEX_VIRTUAL_PATH",
"SKILLS_DIR_NAME",
"SKILLS_VIRTUAL_PATH",
"AdvancedAgentGraphState",
"BackendFactory",
"BackendProtocol",
Expand Down
29 changes: 19 additions & 10 deletions src/uipath_langchain/agent/advanced/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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,
Expand All @@ -50,6 +53,7 @@ def create_advanced_agent(
backend=backend,
response_format=response_format,
memory=list(memory) or None,
skills=list(skills) or None,
)


Expand All @@ -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,
Expand All @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions src/uipath_langchain/agent/advanced/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <workspace>/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,
Expand Down
6 changes: 5 additions & 1 deletion src/uipath_langchain/agent/tools/internal_tools/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
163 changes: 163 additions & 0 deletions src/uipath_langchain/agent/tools/internal_tools/uipath_cli_tool.py
Original file line number Diff line number Diff line change
@@ -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(

Check failure on line 55 in src/uipath_langchain/agent/tools/internal_tools/uipath_cli_tool.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 21 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-langchain-python&issues=AZ9DvEcbhW9_xDpdgGtz&open=AZ9DvEcbhW9_xDpdgGtz&pullRequest=977
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 ``<verb>-``.
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
80 changes: 80 additions & 0 deletions tests/agent/advanced/test_skills_injection.py
Original file line number Diff line number Diff line change
@@ -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/"]
Loading
Loading