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
9 changes: 9 additions & 0 deletions src/uipath_langchain/agent/advanced/agent.py
Comment thread
mariadhakalUipath marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,16 @@ def create_advanced_agent(
backend: BackendProtocol | BackendFactory | None = None,
response_format: ResponseFormat[Any] | None = None,
memory: Sequence[str] = (),
skills: Sequence[str] | None = None,
) -> 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.

``skills`` is a list of skill source paths for deepagents' ``SkillsMiddleware``;
``None`` or empty disables it (mirroring ``_create_deep_agent``'s contract).
"""
return _create_deep_agent(
model=model,
Expand All @@ -50,6 +54,7 @@ def create_advanced_agent(
backend=backend,
response_format=response_format,
memory=list(memory) or None,
skills=list(skills) if skills else None,
)
Comment thread
mariadhakalUipath marked this conversation as resolved.
Comment thread
mariadhakalUipath marked this conversation as resolved.


Expand All @@ -62,6 +67,7 @@ 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.

Expand All @@ -82,6 +88,7 @@ def create_advanced_agent_graph(
backend=backend,
response_format=response_format,
memory=memory_sources,
skills=skills,
)

wrapper_state = create_state_with_input(input_schema)
Expand Down Expand Up @@ -128,6 +135,7 @@ def create_conversational_advanced_agent_graph(
tools: Sequence[BaseTool],
system_prompt: str,
backend: BackendProtocol | BackendFactory | None,
skills: Sequence[str] | None = None,
) -> StateGraph[Any, Any, Any, Any]:
"""Wrap the advanced agent in a parent graph that speaks the conversational contract.

Expand All @@ -150,6 +158,7 @@ def create_conversational_advanced_agent_graph(
system_prompt=system_prompt,
backend=backend,
memory=memory_sources,
skills=skills,
)

class ConversationalAdvancedAgentOutput(BaseModel):
Expand Down
3 changes: 2 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,6 @@
"""Internal Tool creation and management for LowCode agents."""
Comment thread
mariadhakalUipath marked this conversation as resolved.

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"]
273 changes: 273 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,273 @@
"""Sandboxed ``uip`` CLI runner injected into advanced agents.

Unlike the resource-selected internal tools built by ``create_internal_tool``, this
tool is injected programmatically by the agent graph builder when UiPath skills are
active — the agent invokes it to run the ``uip`` commands a skill prescribes. It runs
exactly one ``uip`` invocation per call.
Comment thread
mariadhakalUipath marked this conversation as resolved.

"""

import asyncio
import logging
import os
import shlex
import shutil
from pathlib import Path
from typing import Any

from langchain_core.tools import StructuredTool
from pydantic import BaseModel, Field, field_validator
from uipath.eval.mocks import mockable
from uipath.runtime import Workspace

from uipath_langchain.agent.tools.structured_tool_with_argument_properties import (
StructuredToolWithArgumentProperties,
)

logger = logging.getLogger("uipath")

_TOOL_NAME = "uipath_cli"

_TOOL_DESCRIPTION = (
"Run a single UiPath `uip` command in the agent workspace; returns "
"exit_code/stdout/stderr. Only the uip/uipath binaries, one command per call; "
"destructive subcommands and shell chaining are refused. A negative exit_code "
"means the command was refused before it ran and stderr explains why. Use "
"`subdir` to target a scaffolded project folder."
)

_ALLOWED_BINARIES: tuple[str, ...] = ("uip", "uipath")

# Distinct from any real process exit status: normal exits are 0-255 and
# signal-terminated processes surface as -1..-64, so a value well below -255
# unambiguously marks a command rejected before it ever ran.
_REJECTED_EXIT_CODE = -1000

_COMMAND_TIMEOUT_SECONDS = 600
Comment thread
mariadhakalUipath marked this conversation as resolved.

_SHELL_OPERATORS: frozenset[str] = frozenset({"&&", "||", ";", "|"})

# Adding this list for the first iteration of the tool, but we may not need it in the future.
_DESTRUCTIVE_SUBCOMMANDS: frozenset[str] = frozenset(
Comment thread
mariadhakalUipath marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the list of disallowed commands should sit next to the CLI so they can be versioned together. what happens if a new CLI binary is available on the serverless image (containing a destructive command that is not blackilsted here; like remove tenant or similar) but the coded agent is pinned to this SDK version?

I suggest we add support on the CLI side, a new global flag like —safe or —disallow-destructive-command and block the calls at CLI level. our harness should then always append this flag to the commands it invokes

{"delete", "remove", "destroy", "uninstall", "purge"}
)


def _parse_uip_command(command: str) -> list[str]:
"""Validate a single ``uip`` command and return its argv (binary excluded).

Args:
command: The command to run, e.g. ``"pack"`` or ``"solution publish"``.
An explicit ``uip``/``uipath`` prefix is tolerated and stripped.

Returns:
The argument tokens to pass after the resolved binary.

Raises:
ValueError: If the command is empty, chains multiple commands via a shell
operator, or invokes a destructive subcommand.
"""
try:
lexer = shlex.shlex(command, posix=True)
lexer.whitespace_split = True
lexer.commenters = ""
lexer.escape = ""
tokens = list(lexer)
except ValueError as exc:
raise ValueError(f"Could not parse command: {exc}") from exc
Comment thread
Copilot marked this conversation as resolved.
Comment thread
Copilot marked this conversation as resolved.

if not tokens:
raise ValueError("No command provided.")

if tokens[0] in _ALLOWED_BINARIES:
tokens = tokens[1:]
Comment thread
mariadhakalUipath marked this conversation as resolved.
if not tokens:
raise ValueError("No `uip` subcommand provided.")

# One pass rejects both shell operators and destructive subcommands. The operator
# check is defense-in-depth: execution uses ``create_subprocess_exec`` (never a
# shell), so chaining cannot happen regardless — this just returns a clear error
# instead of handing a nonsense token to ``uip``.
for token in tokens:
if token in _SHELL_OPERATORS:
raise ValueError(
f"Shell operator '{token}' is not allowed; run one command at a time."
)
if token.lower() in _DESTRUCTIVE_SUBCOMMANDS:
raise ValueError(f"Destructive subcommand '{token}' is not allowed.")

return tokens


class UiPathCliInput(BaseModel):
"""Input schema for the ``uipath_cli`` tool."""

command: str = Field(
description=(
"A single `uip` command without the binary prefix, e.g. `pack`, "
"`solution publish`. One command only; no shell operators."
),
examples=["pack", "solution publish", "init my-agent"],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let s include valid commands in the examples here.
eg init my-agent -> codedagent init

)
subdir: str = Field(
default="",
description="Workspace-relative directory to run in; defaults to the root.",
)

Comment thread
mariadhakalUipath marked this conversation as resolved.
@field_validator("command")
@classmethod
def _validate_command(cls, value: str) -> str:
"""Reject unsafe commands before execution (see ``_parse_uip_command``)."""
_parse_uip_command(value)
return value


class UiPathCliOutput(BaseModel):
"""Output schema for the ``uipath_cli`` tool."""

command: str = Field(description="The argv actually executed (or attempted).")
exit_code: int = Field(
description=(
f"Process exit code, or {_REJECTED_EXIT_CODE} when the command was "
"rejected before running (see stderr for the reason)."
)
)
stdout: str = Field(description="Captured standard output.")
stderr: str = Field(description="Captured standard error, or the failure reason.")


def _rejected(command: str, reason: str) -> dict[str, Any]:
"""Build a 'rejected before running' result carrying the sentinel and reason."""
logger.warning("uipath_cli rejected command %r: %s", command, reason)
return UiPathCliOutput(
command=command,
exit_code=_REJECTED_EXIT_CODE,
stdout="",
stderr=reason,
).model_dump()


def _resolve_run_dir_within_workspace(workspace_root: Path, subdir: str) -> Path | None:
"""Resolve ``subdir`` under the workspace, or ``None`` if it escapes.

``.resolve()`` collapses ``..`` and follows symlinks, so absolute paths, parent
traversal, and symlink escapes are all rejected.
"""
run_dir = (workspace_root / subdir).resolve()
if run_dir == workspace_root or workspace_root in run_dir.parents:
return run_dir
return None


async def _run_uip_subprocess(
binary: str, args: list[str], run_dir: Path, echoed: str
) -> dict[str, Any]:
"""Run the resolved ``uip`` command in ``run_dir`` and map it to output."""
logger.info("uipath_cli running %s (cwd=%s)", echoed, run_dir)
try:
proc = await asyncio.create_subprocess_exec(
binary,
*args,
cwd=run_dir,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except OSError as exc:
return _rejected(echoed, str(exc))

try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=_COMMAND_TIMEOUT_SECONDS
)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
return _rejected(
echoed, f"Command timed out after {_COMMAND_TIMEOUT_SECONDS}s."
)

logger.info("uipath_cli command %s exited with code %s", echoed, proc.returncode)
exit_code = proc.returncode if proc.returncode is not None else _REJECTED_EXIT_CODE
return UiPathCliOutput(
command=echoed,
exit_code=exit_code,
stdout=stdout.decode(errors="replace"),
stderr=stderr.decode(errors="replace"),
).model_dump()


def create_uipath_cli_tool(workspace: Workspace) -> StructuredTool:
"""Create the sandboxed ``uipath_cli`` tool bound to ``workspace``.

The returned tool runs one ``uip`` command per call inside the workspace
(optionally under ``subdir``) and returns a :class:`UiPathCliOutput` dict. It is
injected by the agent graph builder rather than selected as a resource, and
enforces that only the ``uip``/``uipath`` binaries run, one command at a time,
excluding destructive subcommands.

Args:
workspace: The agent's workspace; commands run within its directory. Must be a
real on-disk workspace (inject only for a ``FilesystemBackend``).

Returns:
A ``StructuredTool`` named ``uipath_cli``.
"""

async def run_uipath_command(command: str, subdir: str = "") -> dict[str, Any]:
Comment thread
mariadhakalUipath marked this conversation as resolved.
# Parse again here to obtain the argv we exec (and to stay correct if this
# coroutine is invoked directly, bypassing the model validator). Pure and
# deterministic, so it stays outside the mockable body below.
try:
args = _parse_uip_command(command)
except ValueError as exc:
return _rejected(command.strip() or "uip", str(exc))

@mockable(
name=_TOOL_NAME,
description=_TOOL_DESCRIPTION,
input_schema=UiPathCliInput.model_json_schema(),
output_schema=UiPathCliOutput.model_json_schema(),
example_calls=[], # Examples cannot be provided for internal tools
)
async def _execute(**_tool_kwargs: Any) -> dict[str, Any]:
# Wrapped by @mockable so eval runs get a deterministic result without a
# real binary/workspace — hence binary resolution and the workspace guard
# live here, above the subprocess, so the mock intercepts before them.
binary = shutil.which("uip") or shutil.which("uipath")
if binary is None:
return _rejected(
shlex.join(["uip", *args]),
"No `uip` or `uipath` binary found on PATH.",
)
echoed = shlex.join([os.path.basename(binary), *args])

run_dir = _resolve_run_dir_within_workspace(
workspace.path.resolve(), subdir
)
if run_dir is None:
return _rejected(
echoed, f"Invalid subdir '{subdir}': escapes the workspace."
)

return await _run_uip_subprocess(binary, args, run_dir, echoed)

return await _execute(command=command, subdir=subdir)

return StructuredToolWithArgumentProperties(
name=_TOOL_NAME,
description=_TOOL_DESCRIPTION,
args_schema=UiPathCliInput,
coroutine=run_uipath_command,
output_type=UiPathCliOutput,
argument_properties={},
metadata={
"tool_type": _TOOL_NAME,
"display_name": _TOOL_NAME,
"args_schema": UiPathCliInput,
"output_schema": UiPathCliOutput,
},
# Validator errors become a recoverable ToolMessage in all modes (the repo
# only auto-wraps tool errors in conversational mode).
handle_validation_error=lambda e: str(e),
)
24 changes: 24 additions & 0 deletions tests/agent/advanced/test_create_advanced_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,27 @@ def test_advanced_agent_converts_sequences_to_lists(
_, kwargs = mock_upstream.call_args
assert isinstance(kwargs["tools"], list)
assert isinstance(kwargs["subagents"], list)

def test_advanced_agent_forwards_skills(self, mock_model: MagicMock) -> None:
"""Non-empty skills are forwarded to the upstream builder as a list."""
with patch(
"uipath_langchain.agent.advanced.agent._create_deep_agent"
) as mock_upstream:
mock_upstream.return_value = MagicMock(spec=CompiledStateGraph)
create_advanced_agent(
mock_model, system_prompt="test", skills=("/skills/",)
)
_, kwargs = mock_upstream.call_args
assert kwargs["skills"] == ["/skills/"]

def test_advanced_agent_empty_skills_becomes_none(
self, mock_model: MagicMock
) -> None:
"""An empty skills sequence collapses to None (disables the middleware)."""
with patch(
"uipath_langchain.agent.advanced.agent._create_deep_agent"
) as mock_upstream:
mock_upstream.return_value = MagicMock(spec=CompiledStateGraph)
create_advanced_agent(mock_model, system_prompt="test")
_, kwargs = mock_upstream.call_args
assert kwargs["skills"] is None
Loading
Loading