Skip to content
Closed
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
19 changes: 12 additions & 7 deletions src/uipath_langchain/agent/advanced/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
MEMORY_INDEX_VIRTUAL_PATH,
create_state_with_input,
resolve_input_attachments,
resolve_skill_sources,
)


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,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.
Comment on lines 73 to +76
"""
memory_sources = (
[MEMORY_INDEX_VIRTUAL_PATH] if isinstance(backend, FilesystemBackend) else []
Expand All @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions src/uipath_langchain/agent/advanced/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <workspace>/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,
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",
]
210 changes: 210 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,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]}"
)
Comment on lines +150 to +154


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,
},
)
Loading
Loading