Skip to content

Commit e5d2b48

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 30d8e85 commit e5d2b48

7 files changed

Lines changed: 371 additions & 4 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: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from .types import AdvancedAgentGraphState, ConversationalAdvancedAgentGraphState
2323
from .utils import (
2424
MEMORY_INDEX_VIRTUAL_PATH,
25+
SKILLS_VIRTUAL_PATH,
2526
create_state_with_input,
2627
resolve_input_attachments,
2728
)
@@ -35,12 +36,18 @@ 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] | None = None,
3840
) -> CompiledStateGraph[Any, Any, Any, Any]:
3941
"""Create a deepagents agent with planning, filesystem, and sub-agent tools.
4042
4143
``memory`` is a list of file paths loaded via deepagents' ``MemoryMiddleware``:
4244
each is read from ``backend`` and injected into the system prompt every turn,
4345
and the model maintains them with ``edit_file``. Empty disables the middleware.
46+
47+
``skills`` is a list of source directories loaded via deepagents'
48+
``SkillsMiddleware``, surfaced to the model with progressive disclosure;
49+
``None`` disables it. Workspace tools such as ``uipath_cli`` are passed by the
50+
caller via ``tools``, not injected here.
4451
"""
4552
return _create_deep_agent(
4653
model=model,
@@ -50,6 +57,7 @@ def create_advanced_agent(
5057
backend=backend,
5158
response_format=response_format,
5259
memory=list(memory) or None,
60+
skills=list(skills) if skills is not None else None,
5361
)
5462

5563

@@ -62,6 +70,7 @@ def create_advanced_agent_graph(
6270
input_schema: type[BaseModel] | None,
6371
output_schema: type[BaseModel],
6472
build_user_message: Callable[[dict[str, Any]], str],
73+
skills: Sequence[str] | None = None,
6574
) -> StateGraph[Any, Any, Any, Any]:
6675
"""Wrap the advanced agent in a parent graph that maps typed I/O to/from messages.
6776
@@ -70,10 +79,20 @@ def create_advanced_agent_graph(
7079
``FilesystemBackend`` also enables workspace memory: deepagents'
7180
``MemoryMiddleware`` reads ``/memory/MEMORY.md`` from the backend each turn.
7281
Memory stays disabled for non-filesystem backends, which carry no workspace.
82+
83+
Skills are opt-in, unlike memory: enabled only when the caller passes a
84+
non-empty ``skills`` argument. With a ``FilesystemBackend`` the workspace
85+
``/skills/`` mount is prepended so sources resolve under the workspace. Any
86+
workspace tools (e.g. ``uipath_cli``) are passed by the caller via ``tools``.
7387
"""
74-
memory_sources = (
75-
[MEMORY_INDEX_VIRTUAL_PATH] if isinstance(backend, FilesystemBackend) else []
76-
)
88+
is_filesystem = isinstance(backend, FilesystemBackend)
89+
memory_sources = [MEMORY_INDEX_VIRTUAL_PATH] if is_filesystem else []
90+
skills_sources: list[str] | None = None
91+
if skills:
92+
sources = list(skills)
93+
if is_filesystem and SKILLS_VIRTUAL_PATH not in sources:
94+
sources.insert(0, SKILLS_VIRTUAL_PATH)
95+
skills_sources = sources
7796

7897
inner_graph = create_advanced_agent(
7998
model=model,
@@ -82,6 +101,7 @@ def create_advanced_agent_graph(
82101
backend=backend,
83102
response_format=response_format,
84103
memory=memory_sources,
104+
skills=skills_sources,
85105
)
86106

87107
wrapper_state = create_state_with_input(input_schema)

src/uipath_langchain/agent/advanced/utils.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@
3030
# FilesystemBackend resolves it under the workspace root.
3131
MEMORY_INDEX_VIRTUAL_PATH = f"/{MEMORY_DIR_NAME}/{MEMORY_INDEX_FILENAME}"
3232

33+
# Skills live under <workspace>/skills/: each is a directory with a SKILL.md,
34+
# loaded by deepagents' SkillsMiddleware with progressive disclosure.
35+
SKILLS_DIR_NAME = "skills"
36+
37+
# Virtual source path handed to SkillsMiddleware; the virtual-mode
38+
# FilesystemBackend resolves it under the workspace root.
39+
SKILLS_VIRTUAL_PATH = f"/{SKILLS_DIR_NAME}/"
40+
3341

3442
def create_state_with_input(
3543
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: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Workspace CLI tool injected when skills are active.
2+
3+
``uipath_cli`` runs ``uipath`` / ``uip`` commands a skill prescribes, jailed to
4+
the agent's workspace.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import os
10+
import shlex
11+
import subprocess
12+
from collections.abc import Iterable
13+
from pathlib import Path
14+
from typing import Annotated
15+
16+
from langchain_core.tools import BaseTool, tool
17+
18+
ALLOWED_BINARIES = {"uipath", "uip"}
19+
MAX_LOG_BYTES = 4000
20+
21+
# Destructive verbs blocked by default.
22+
DEFAULT_DENIED_SUBCOMMANDS = frozenset(
23+
{
24+
"delete",
25+
"remove",
26+
"cancel",
27+
"uninstall",
28+
"unassign",
29+
"unlink",
30+
"unset",
31+
"clear",
32+
"rm",
33+
"del",
34+
"destroy",
35+
"purge",
36+
"drop",
37+
"revoke",
38+
"deactivate",
39+
"undeploy",
40+
"unpublish",
41+
"unregister",
42+
"unimport",
43+
"prune",
44+
"wipe",
45+
"reset",
46+
}
47+
)
48+
49+
__all__ = [
50+
"create_uipath_cli_tool",
51+
"DEFAULT_DENIED_SUBCOMMANDS",
52+
]
53+
54+
55+
def create_uipath_cli_tool(
56+
workspace: Path | str,
57+
timeout_sec: int = 120,
58+
denied_subcommands: Iterable[str] | None = None,
59+
) -> BaseTool:
60+
"""Build a CLI tool bound to ``workspace``.
61+
62+
Pass the resulting tool via the ``tools`` argument of the advanced agent
63+
builders — inject it only for UiPath skills, not customer skills.
64+
65+
Args:
66+
workspace: Root for CLI invocations, created if absent. Commands cannot
67+
escape it; subdirectories are created on demand.
68+
timeout_sec: Per-command subprocess timeout. Default 120s.
69+
denied_subcommands: Blocked verbs; a command is rejected when a non-flag
70+
token equals one (case-insensitive) or starts with ``<verb>-``.
71+
Defaults to :data:`DEFAULT_DENIED_SUBCOMMANDS`; pass an empty
72+
iterable to allow everything.
73+
"""
74+
workspace_path = Path(workspace).expanduser().resolve()
75+
workspace_path.mkdir(parents=True, exist_ok=True)
76+
denied = frozenset(
77+
s.lower()
78+
for s in (
79+
DEFAULT_DENIED_SUBCOMMANDS
80+
if denied_subcommands is None
81+
else denied_subcommands
82+
)
83+
)
84+
85+
@tool
86+
def uipath_cli(
87+
command: Annotated[
88+
str,
89+
"Full CLI command starting with `uipath` or `uip`, e.g. 'uipath pack' or 'uip solution publish dev'.",
90+
],
91+
subdir: Annotated[
92+
str,
93+
"Workspace-relative subdirectory to run in. Give each scaffolded project its own subdir. Defaults to the workspace root.",
94+
] = "",
95+
) -> str:
96+
"""Run a ``uipath`` / ``uip`` command in a workspace subdirectory.
97+
98+
Returns combined stdout/stderr. Only ``uipath`` and ``uip`` are allowed.
99+
Inherits the agent process environment, including ``UIPATH_ACCESS_TOKEN``
100+
/ ``UIPATH_BASE_URL`` for tenant auth.
101+
"""
102+
run_dir = workspace_path
103+
if subdir:
104+
candidate = (workspace_path / subdir).resolve()
105+
if not candidate.is_relative_to(workspace_path):
106+
return f"ERROR: subdir '{subdir}' escapes the workspace directory."
107+
candidate.mkdir(parents=True, exist_ok=True)
108+
run_dir = candidate
109+
110+
try:
111+
argv = shlex.split(command)
112+
except ValueError as e:
113+
return f"ERROR: could not parse command: {e}"
114+
115+
if not argv:
116+
return "ERROR: empty command"
117+
118+
binary = os.path.basename(argv[0])
119+
if binary not in ALLOWED_BINARIES:
120+
return (
121+
f"ERROR: '{binary}' is not allowed. Only these binaries are "
122+
f"permitted: {sorted(ALLOWED_BINARIES)}"
123+
)
124+
125+
def is_denied(tok: str) -> bool:
126+
low = tok.lower()
127+
return any(low == d or low.startswith(f"{d}-") for d in denied)
128+
129+
blocked = next(
130+
(tok for tok in argv[1:] if not tok.startswith("-") and is_denied(tok)),
131+
None,
132+
)
133+
if blocked is not None:
134+
return (
135+
f"ERROR: subcommand '{blocked}' is not allowed. These destructive "
136+
f"subcommands are blocked: {sorted(denied)}"
137+
)
138+
139+
try:
140+
proc = subprocess.run(
141+
argv,
142+
cwd=str(run_dir),
143+
capture_output=True,
144+
text=True,
145+
timeout=timeout_sec,
146+
check=False,
147+
)
148+
except subprocess.TimeoutExpired:
149+
return f"ERROR: command timed out after {timeout_sec}s"
150+
except FileNotFoundError:
151+
return f"ERROR: '{binary}' not found on PATH. Install the UiPath CLI."
152+
153+
return (
154+
f"exit_code: {proc.returncode}\n"
155+
f"--- stdout ---\n{proc.stdout[:MAX_LOG_BYTES]}\n"
156+
f"--- stderr ---\n{proc.stderr[:MAX_LOG_BYTES]}"
157+
)
158+
159+
# Classify as "internal" so traces label it (untyped default is "Integration").
160+
uipath_cli.metadata = {"tool_type": "internal", "display_name": "uipath_cli"}
161+
return uipath_cli
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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_declared_skills_enable_and_prepend_workspace(self, tmp_path: Any) -> None:
62+
backend = FilesystemBackend(root_dir=tmp_path, virtual_mode=True)
63+
assert _skills_kwarg(backend, skills=["/extra/"]) == [
64+
SKILLS_VIRTUAL_PATH,
65+
"/extra/",
66+
]
67+
68+
def test_no_duplicate_workspace_source(self, tmp_path: Any) -> None:
69+
backend = FilesystemBackend(root_dir=tmp_path, virtual_mode=True)
70+
assert _skills_kwarg(backend, skills=[SKILLS_VIRTUAL_PATH]) == [
71+
SKILLS_VIRTUAL_PATH
72+
]
73+
74+
def test_explicit_sources_only_for_non_filesystem_backend(self) -> None:
75+
assert _skills_kwarg(None, skills=["/extra/"]) == ["/extra/"]

0 commit comments

Comments
 (0)