-
Notifications
You must be signed in to change notification settings - Fork 34
feat(skills): add skills support to advanced agents with sandboxed cli tool #989
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mariadhakalUipath
wants to merge
1
commit into
main
Choose a base branch
from
feat/uipath-skills-for-advanced
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| """Internal Tool creation and management for LowCode agents.""" | ||
|
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"] | ||
242 changes: 242 additions & 0 deletions
242
src/uipath_langchain/agent/tools/internal_tools/uipath_cli_tool.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,242 @@ | ||
| """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. | ||
|
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 | ||
| 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; " | ||
| "shell chaining is 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") | ||
|
|
||
| _REJECTED_EXIT_CODE = -1000 | ||
|
|
||
| _COMMAND_TIMEOUT_SECONDS = 600 | ||
|
mariadhakalUipath marked this conversation as resolved.
|
||
|
|
||
| _SHELL_OPERATORS: frozenset[str] = frozenset({"&&", "||", ";", "|"}) | ||
|
|
||
|
|
||
| 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 or chains multiple commands via a shell | ||
| operator. | ||
| """ | ||
| 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 | ||
|
Copilot marked this conversation as resolved.
Copilot marked this conversation as resolved.
|
||
|
|
||
| if not tokens: | ||
| raise ValueError("No command provided.") | ||
|
|
||
| if tokens[0] in _ALLOWED_BINARIES: | ||
| tokens = tokens[1:] | ||
|
mariadhakalUipath marked this conversation as resolved.
|
||
| if not tokens: | ||
| raise ValueError("No `uip` subcommand provided.") | ||
|
|
||
| for token in tokens: | ||
| if token in _SHELL_OPERATORS: | ||
| raise ValueError( | ||
| f"Shell operator '{token}' is not allowed; run one command at a time." | ||
| ) | ||
|
|
||
| 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", "codeagent init"], | ||
| ) | ||
| subdir: str = Field( | ||
| default="", | ||
| description="Workspace-relative directory to run in; defaults to the root.", | ||
| ) | ||
|
|
||
|
|
||
| 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. | ||
|
|
||
| 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]: | ||
|
mariadhakalUipath marked this conversation as resolved.
|
||
| 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=[], | ||
| ) | ||
| async def _execute(**_tool_kwargs: Any) -> dict[str, Any]: | ||
| 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, | ||
| }, | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.