Skip to content

Commit 85bf05e

Browse files
feat: add skills feature to advanced agent with workspace to load skills
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5a837af commit 85bf05e

7 files changed

Lines changed: 447 additions & 8 deletions

File tree

src/uipath_langchain/agent/advanced/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@
1414
MEMORY_DIR_NAME,
1515
MEMORY_INDEX_FILENAME,
1616
MEMORY_INDEX_VIRTUAL_PATH,
17+
SKILLS_DIR_NAME,
18+
SKILLS_VIRTUAL_PATH,
1719
create_state_with_input,
1820
)
1921

2022
__all__ = [
2123
"MEMORY_DIR_NAME",
2224
"MEMORY_INDEX_FILENAME",
2325
"MEMORY_INDEX_VIRTUAL_PATH",
26+
"SKILLS_DIR_NAME",
27+
"SKILLS_VIRTUAL_PATH",
2428
"AdvancedAgentGraphState",
2529
"BackendFactory",
2630
"BackendProtocol",

src/uipath_langchain/agent/advanced/agent.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
MEMORY_INDEX_VIRTUAL_PATH,
2525
create_state_with_input,
2626
resolve_input_attachments,
27+
resolve_skill_sources,
2728
)
2829

2930

@@ -35,12 +36,14 @@ def create_advanced_agent(
3536
backend: BackendProtocol | BackendFactory | None = None,
3637
response_format: ResponseFormat[Any] | None = None,
3738
memory: Sequence[str] = (),
39+
skills: Sequence[str] = (),
3840
) -> CompiledStateGraph[Any, Any, Any, Any]:
3941
"""Create a deepagents agent with planning, filesystem, and sub-agent tools.
4042
41-
``memory`` is a list of file paths loaded via deepagents' ``MemoryMiddleware``:
42-
each is read from ``backend`` and injected into the system prompt every turn,
43-
and the model maintains them with ``edit_file``. Empty disables the middleware.
43+
``memory`` (file paths) and ``skills`` (source directories) are loaded via
44+
deepagents' ``MemoryMiddleware`` and ``SkillsMiddleware``; an empty sequence
45+
disables each. Workspace tools like ``uipath_cli`` are supplied by the caller
46+
via ``tools``.
4447
"""
4548
return _create_deep_agent(
4649
model=model,
@@ -50,6 +53,7 @@ def create_advanced_agent(
5053
backend=backend,
5154
response_format=response_format,
5255
memory=list(memory) or None,
56+
skills=list(skills) or None,
5357
)
5458

5559

@@ -62,14 +66,14 @@ def create_advanced_agent_graph(
6266
input_schema: type[BaseModel] | None,
6367
output_schema: type[BaseModel],
6468
build_user_message: Callable[[dict[str, Any]], str],
69+
skills: Sequence[str] | None = None,
6570
) -> StateGraph[Any, Any, Any, Any]:
6671
"""Wrap the advanced agent in a parent graph that maps typed I/O to/from messages.
6772
6873
With a ``FilesystemBackend``, attachment-shaped inputs are downloaded into the
69-
workspace and given a ``FilePath`` before the user message is built. A
70-
``FilesystemBackend`` also enables workspace memory: deepagents'
71-
``MemoryMiddleware`` reads ``/memory/MEMORY.md`` from the backend each turn.
72-
Memory stays disabled for non-filesystem backends, which carry no workspace.
74+
workspace, memory (``/memory/MEMORY.md``) is enabled, and the workspace
75+
``skills/`` dir is prepended to any declared ``skills``. Both stay disabled for
76+
non-filesystem backends, which carry no workspace.
7377
"""
7478
memory_sources = (
7579
[MEMORY_INDEX_VIRTUAL_PATH] if isinstance(backend, FilesystemBackend) else []
@@ -82,6 +86,7 @@ def create_advanced_agent_graph(
8286
backend=backend,
8387
response_format=response_format,
8488
memory=memory_sources,
89+
skills=resolve_skill_sources(backend, skills),
8590
)
8691

8792
wrapper_state = create_state_with_input(input_schema)

src/uipath_langchain/agent/advanced/utils.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import copy
55
import logging
66
import uuid
7+
from collections.abc import Sequence
78
from pathlib import Path
89
from typing import Any, NamedTuple, cast
910

@@ -30,6 +31,28 @@
3031
# FilesystemBackend resolves it under the workspace root.
3132
MEMORY_INDEX_VIRTUAL_PATH = f"/{MEMORY_DIR_NAME}/{MEMORY_INDEX_FILENAME}"
3233

34+
# Skills live under <workspace>/skills/
35+
SKILLS_DIR_NAME = "skills"
36+
37+
SKILLS_VIRTUAL_PATH = f"/{SKILLS_DIR_NAME}/"
38+
39+
40+
def resolve_skill_sources(
41+
backend: BackendProtocol | BackendFactory | None,
42+
skills: Sequence[str] | None,
43+
) -> list[str]:
44+
"""Resolve the skill sources for ``SkillsMiddleware``.
45+
46+
With a ``FilesystemBackend`` the workspace ``skills/`` dir is prepended to the
47+
declared ``skills``; other backends carry no workspace, so only ``skills`` is used.
48+
"""
49+
if not skills:
50+
return []
51+
sources = list(skills)
52+
if isinstance(backend, FilesystemBackend) and SKILLS_VIRTUAL_PATH not in sources:
53+
sources.insert(0, SKILLS_VIRTUAL_PATH)
54+
return sources
55+
3356

3457
def create_state_with_input(
3558
input_schema: type[BaseModel] | None,
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
"""Internal Tool creation and management for LowCode agents."""
22

33
from .internal_tool_factory import create_internal_tool
4+
from .uipath_cli_tool import create_uipath_cli_tool
45

5-
__all__ = ["create_internal_tool"]
6+
__all__ = [
7+
"create_internal_tool",
8+
"create_uipath_cli_tool",
9+
]
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"""Workspace CLI tool injected when skills are active."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import shlex
7+
import subprocess
8+
from collections.abc import Iterable
9+
from pathlib import Path
10+
11+
from langchain_core.tools import BaseTool
12+
from pydantic import BaseModel, Field
13+
from uipath.runtime import Workspace
14+
15+
from uipath_langchain.agent.tools.base_uipath_structured_tool import (
16+
BaseUiPathStructuredTool,
17+
)
18+
19+
ALLOWED_BINARIES = {"uipath", "uip"}
20+
MAX_LOG_BYTES = 4000
21+
22+
# Classify as "internal" so traces label it (untyped default is "Integration").
23+
UIPATH_CLI_TOOL_NAME = "uipath_cli"
24+
UIPATH_CLI_TOOL_TYPE = "internal"
25+
26+
# Destructive verbs blocked by default.
27+
DEFAULT_DENIED_SUBCOMMANDS = frozenset(
28+
{
29+
"delete",
30+
"remove",
31+
"cancel",
32+
"uninstall",
33+
"unassign",
34+
"unlink",
35+
"unset",
36+
"clear",
37+
"rm",
38+
"del",
39+
"destroy",
40+
"purge",
41+
"drop",
42+
"revoke",
43+
"deactivate",
44+
"undeploy",
45+
"unpublish",
46+
"unregister",
47+
"unimport",
48+
"prune",
49+
"wipe",
50+
"reset",
51+
}
52+
)
53+
54+
__all__ = [
55+
"create_uipath_cli_tool",
56+
"DEFAULT_DENIED_SUBCOMMANDS",
57+
"UIPATH_CLI_TOOL_NAME",
58+
"UIPATH_CLI_TOOL_TYPE",
59+
]
60+
61+
_TOOL_DESCRIPTION = (
62+
"Run a `uipath` / `uip` command in a workspace subdirectory. Returns combined "
63+
"stdout/stderr. Only `uipath` and `uip` are allowed. Inherits the agent process "
64+
"environment, including `UIPATH_ACCESS_TOKEN` / `UIPATH_BASE_URL` for tenant auth."
65+
)
66+
67+
68+
class UiPathCliArgs(BaseModel):
69+
"""Arguments for ``uipath_cli``."""
70+
71+
command: str = Field(
72+
description=(
73+
"Full CLI command starting with `uipath` or `uip`, e.g. 'uipath pack' "
74+
"or 'uip solution publish dev'."
75+
),
76+
)
77+
subdir: str = Field(
78+
default="",
79+
description=(
80+
"Workspace-relative subdirectory to run in. Give each scaffolded "
81+
"project its own subdir. Defaults to the workspace root."
82+
),
83+
)
84+
85+
86+
def _resolve_run_dir(workspace_path: Path, subdir: str) -> Path | str:
87+
"""Resolve ``subdir`` under the workspace"""
88+
if not subdir:
89+
return workspace_path
90+
candidate = (workspace_path / subdir).resolve()
91+
if not candidate.is_relative_to(workspace_path):
92+
return f"ERROR: subdir '{subdir}' escapes the workspace directory."
93+
return candidate
94+
95+
96+
def _is_denied_token(tok: str, denied: frozenset[str]) -> bool:
97+
low = tok.lower()
98+
return any(low == d or low.startswith(f"{d}-") for d in denied)
99+
100+
101+
def _validate_command(argv: list[str], denied: frozenset[str]) -> str | None:
102+
"""Return an ERROR string if the command is empty, uses a disallowed binary, or
103+
contains a blocked destructive subcommand; else ``None``."""
104+
if not argv:
105+
return "ERROR: empty command"
106+
107+
binary = argv[0]
108+
# Reject any path-qualified binary (e.g. ./uip, /tmp/uip) so only the
109+
# UiPath CLI resolved from PATH runs, not a lookalike in the workspace.
110+
if os.path.basename(binary) != binary or binary not in ALLOWED_BINARIES:
111+
return (
112+
f"ERROR: '{binary}' is not allowed. Only these binaries are "
113+
f"permitted (by name, without a path): {sorted(ALLOWED_BINARIES)}"
114+
)
115+
116+
blocked = next(
117+
(
118+
tok
119+
for tok in argv[1:]
120+
if not tok.startswith("-") and _is_denied_token(tok, denied)
121+
),
122+
None,
123+
)
124+
if blocked is not None:
125+
return (
126+
f"ERROR: subcommand '{blocked}' is not allowed. These destructive "
127+
f"subcommands are blocked: {sorted(denied)}"
128+
)
129+
return None
130+
131+
132+
def _execute_cli(argv: list[str], run_dir: Path, timeout_sec: int) -> str:
133+
"""Run ``argv`` in ``run_dir`` and return combined exit code / stdout / stderr."""
134+
try:
135+
proc = subprocess.run(
136+
argv,
137+
cwd=str(run_dir),
138+
capture_output=True,
139+
text=True,
140+
encoding="utf-8",
141+
errors="replace",
142+
timeout=timeout_sec,
143+
check=False,
144+
)
145+
except subprocess.TimeoutExpired:
146+
return f"ERROR: command timed out after {timeout_sec}s"
147+
except FileNotFoundError:
148+
return f"ERROR: '{argv[0]}' not found on PATH. Install the UiPath CLI."
149+
150+
return (
151+
f"exit_code: {proc.returncode}\n"
152+
f"--- stdout ---\n{proc.stdout[:MAX_LOG_BYTES]}\n"
153+
f"--- stderr ---\n{proc.stderr[:MAX_LOG_BYTES]}"
154+
)
155+
156+
157+
def _run_uipath_cli(
158+
command: str,
159+
subdir: str,
160+
workspace_path: Path,
161+
denied: frozenset[str],
162+
timeout_sec: int,
163+
) -> str:
164+
"""Validate and run a single ``uipath`` / ``uip`` command, jailed to the workspace."""
165+
run_dir = _resolve_run_dir(workspace_path, subdir)
166+
if isinstance(run_dir, str): # ERROR string
167+
return run_dir
168+
169+
try:
170+
argv = shlex.split(command)
171+
except ValueError as e:
172+
return f"ERROR: could not parse command: {e}"
173+
174+
error = _validate_command(argv, denied)
175+
if error is not None:
176+
return error
177+
178+
return _execute_cli(argv, run_dir, timeout_sec)
179+
180+
181+
def create_uipath_cli_tool(
182+
workspace: Workspace,
183+
timeout_sec: int = 120,
184+
denied_subcommands: Iterable[str] | None = None,
185+
) -> BaseTool:
186+
"""Build a ``uipath_cli`` tool jailed to ``workspace`` for advanced agents with skills."""
187+
workspace_path = Path(workspace.path).expanduser().resolve()
188+
denied = frozenset(
189+
s.lower()
190+
for s in (
191+
DEFAULT_DENIED_SUBCOMMANDS
192+
if denied_subcommands is None
193+
else denied_subcommands
194+
)
195+
)
196+
197+
def run_uipath_cli(command: str, subdir: str = "") -> str:
198+
return _run_uipath_cli(command, subdir, workspace_path, denied, timeout_sec)
199+
200+
return BaseUiPathStructuredTool(
201+
name=UIPATH_CLI_TOOL_NAME,
202+
description=_TOOL_DESCRIPTION,
203+
args_schema=UiPathCliArgs,
204+
func=run_uipath_cli,
205+
metadata={
206+
"tool_type": UIPATH_CLI_TOOL_TYPE,
207+
"display_name": UIPATH_CLI_TOOL_NAME,
208+
"args_schema": UiPathCliArgs,
209+
},
210+
)

0 commit comments

Comments
 (0)