Skip to content

Commit 55a9031

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 55a9031

7 files changed

Lines changed: 411 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: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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 create_uipath_cli_tool(
87+
workspace: Workspace,
88+
timeout_sec: int = 120,
89+
denied_subcommands: Iterable[str] | None = None,
90+
) -> BaseTool:
91+
"""Build a ``uipath_cli`` tool jailed to ``workspace`` for advanced agent builders with UiPath skills.
92+
The tool only reads ``workspace.path``; the workspace lifecycle (creation, cleanup) stays owned by the runtime.
93+
"""
94+
workspace_path = Path(workspace.path).expanduser().resolve()
95+
workspace_path.mkdir(parents=True, exist_ok=True)
96+
denied = frozenset(
97+
s.lower()
98+
for s in (
99+
DEFAULT_DENIED_SUBCOMMANDS
100+
if denied_subcommands is None
101+
else denied_subcommands
102+
)
103+
)
104+
105+
def run_uipath_cli(command: str, subdir: str = "") -> str:
106+
run_dir = workspace_path
107+
if subdir:
108+
candidate = (workspace_path / subdir).resolve()
109+
if not candidate.is_relative_to(workspace_path):
110+
return f"ERROR: subdir '{subdir}' escapes the workspace directory."
111+
candidate.mkdir(parents=True, exist_ok=True)
112+
run_dir = candidate
113+
114+
try:
115+
argv = shlex.split(command)
116+
except ValueError as e:
117+
return f"ERROR: could not parse command: {e}"
118+
119+
if not argv:
120+
return "ERROR: empty command"
121+
122+
binary = argv[0]
123+
# Reject any path-qualified binary (e.g. ./uip, /tmp/uip) so only the
124+
# UiPath CLI resolved from PATH runs, not a lookalike in the workspace.
125+
if os.path.basename(binary) != binary or binary not in ALLOWED_BINARIES:
126+
return (
127+
f"ERROR: '{binary}' is not allowed. Only these binaries are "
128+
f"permitted (by name, without a path): {sorted(ALLOWED_BINARIES)}"
129+
)
130+
131+
def is_denied(tok: str) -> bool:
132+
low = tok.lower()
133+
return any(low == d or low.startswith(f"{d}-") for d in denied)
134+
135+
blocked = next(
136+
(tok for tok in argv[1:] if not tok.startswith("-") and is_denied(tok)),
137+
None,
138+
)
139+
if blocked is not None:
140+
return (
141+
f"ERROR: subcommand '{blocked}' is not allowed. These destructive "
142+
f"subcommands are blocked: {sorted(denied)}"
143+
)
144+
145+
try:
146+
proc = subprocess.run(
147+
argv,
148+
cwd=str(run_dir),
149+
capture_output=True,
150+
text=True,
151+
timeout=timeout_sec,
152+
check=False,
153+
)
154+
except subprocess.TimeoutExpired:
155+
return f"ERROR: command timed out after {timeout_sec}s"
156+
except FileNotFoundError:
157+
return f"ERROR: '{binary}' not found on PATH. Install the UiPath CLI."
158+
159+
return (
160+
f"exit_code: {proc.returncode}\n"
161+
f"--- stdout ---\n{proc.stdout[:MAX_LOG_BYTES]}\n"
162+
f"--- stderr ---\n{proc.stderr[:MAX_LOG_BYTES]}"
163+
)
164+
165+
return BaseUiPathStructuredTool(
166+
name=UIPATH_CLI_TOOL_NAME,
167+
description=_TOOL_DESCRIPTION,
168+
args_schema=UiPathCliArgs,
169+
func=run_uipath_cli,
170+
metadata={
171+
"tool_type": UIPATH_CLI_TOOL_TYPE,
172+
"display_name": UIPATH_CLI_TOOL_NAME,
173+
"args_schema": UiPathCliArgs,
174+
},
175+
)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Tests for deepagents skills based on the backend wrapper."""
2+
3+
from typing import Any
4+
from unittest.mock import MagicMock, patch
5+
6+
from deepagents.backends.filesystem import FilesystemBackend
7+
from langchain_core.language_models import BaseChatModel
8+
from pydantic import BaseModel
9+
10+
from uipath_langchain.agent.advanced.agent import (
11+
create_advanced_agent_graph,
12+
)
13+
from uipath_langchain.agent.advanced.utils import (
14+
SKILLS_VIRTUAL_PATH,
15+
)
16+
17+
18+
class _Input(BaseModel):
19+
"""Minimal input schema for the test."""
20+
21+
task: str
22+
23+
24+
class _Output(BaseModel):
25+
"""Minimal output schema for the test."""
26+
27+
result: str = ""
28+
29+
30+
def _build_user_message(args: dict[str, Any]) -> str:
31+
return args.get("task", "")
32+
33+
34+
def _skills_kwarg(backend: Any, **overrides: Any) -> Any:
35+
"""Build the wrapper graph and return the ``skills`` kwarg handed to deepagents."""
36+
with patch(
37+
"uipath_langchain.agent.advanced.agent._create_deep_agent",
38+
return_value=MagicMock(),
39+
) as mock_create:
40+
create_advanced_agent_graph(
41+
model=MagicMock(spec=BaseChatModel),
42+
tools=[],
43+
system_prompt="",
44+
backend=backend,
45+
response_format=None,
46+
input_schema=_Input,
47+
output_schema=_Output,
48+
build_user_message=_build_user_message,
49+
**overrides,
50+
)
51+
return mock_create.call_args.kwargs["skills"]
52+
53+
54+
class TestWorkspaceSkillsWiring:
55+
"""Skills are opt-in: enabled only when the caller declares them."""
56+
57+
def test_disabled_by_default_on_filesystem_backend(self, tmp_path: Any) -> None:
58+
backend = FilesystemBackend(root_dir=tmp_path, virtual_mode=True)
59+
assert _skills_kwarg(backend) is None
60+
61+
def test_empty_skills_sequence_disables(self, tmp_path: Any) -> None:
62+
# An empty sequence disables skills, matching the ``memory`` parameter.
63+
backend = FilesystemBackend(root_dir=tmp_path, virtual_mode=True)
64+
assert _skills_kwarg(backend, skills=[]) is None
65+
66+
def test_declared_skills_enable_and_prepend_workspace(self, tmp_path: Any) -> None:
67+
backend = FilesystemBackend(root_dir=tmp_path, virtual_mode=True)
68+
assert _skills_kwarg(backend, skills=["/extra/"]) == [
69+
SKILLS_VIRTUAL_PATH,
70+
"/extra/",
71+
]
72+
73+
def test_no_duplicate_workspace_source(self, tmp_path: Any) -> None:
74+
backend = FilesystemBackend(root_dir=tmp_path, virtual_mode=True)
75+
assert _skills_kwarg(backend, skills=[SKILLS_VIRTUAL_PATH]) == [
76+
SKILLS_VIRTUAL_PATH
77+
]
78+
79+
def test_explicit_sources_only_for_non_filesystem_backend(self) -> None:
80+
assert _skills_kwarg(None, skills=["/extra/"]) == ["/extra/"]

0 commit comments

Comments
 (0)