Skip to content

Commit ca05cf8

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 bae47aa commit ca05cf8

8 files changed

Lines changed: 432 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: 26 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,31 @@
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/: each is a directory with a SKILL.md,
35+
# loaded by deepagents' SkillsMiddleware with progressive disclosure.
36+
SKILLS_DIR_NAME = "skills"
37+
38+
# Virtual source path handed to SkillsMiddleware; the virtual-mode
39+
# FilesystemBackend resolves it under the workspace root.
40+
SKILLS_VIRTUAL_PATH = f"/{SKILLS_DIR_NAME}/"
41+
42+
43+
def resolve_skill_sources(
44+
backend: BackendProtocol | BackendFactory | None,
45+
skills: Sequence[str] | None,
46+
) -> list[str]:
47+
"""Resolve the skill sources for ``SkillsMiddleware``.
48+
49+
With a ``FilesystemBackend`` the workspace ``skills/`` dir is prepended to the
50+
declared ``skills``; other backends carry no workspace, so only ``skills`` is used.
51+
"""
52+
if not skills:
53+
return []
54+
sources = list(skills)
55+
if isinstance(backend, FilesystemBackend) and SKILLS_VIRTUAL_PATH not in sources:
56+
sources.insert(0, SKILLS_VIRTUAL_PATH)
57+
return sources
58+
3359

3460
def create_state_with_input(
3561
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+
]

src/uipath_langchain/agent/tools/internal_tools/internal_tool_factory.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
from .batch_transform_tool import create_batch_transform_tool
3131
from .deeprag_tool import create_deeprag_tool
3232

33+
# ``uipath_cli`` is intentionally absent: it is runtime-injected and bound to the
34+
# agent workspace (see uipath_cli_tool.py), not declared in agent.json. To make it
35+
# user-selectable, add an ``AgentInternalToolType.UIPATH_CLI`` and a handler here that
36+
# resolves the workspace and calls ``create_uipath_cli_tool`` — no other change needed.
3337
_INTERNAL_TOOL_HANDLERS: dict[
3438
AgentInternalToolType,
3539
Callable[[AgentInternalToolResourceConfig, BaseChatModel], StructuredTool],
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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. It is runtime-injected (bound to a workspace path) rather
5+
than declared in ``agent.json``, so it is not registered in
6+
``internal_tool_factory``; see the note there for the user-selectable path.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import os
12+
import shlex
13+
import subprocess
14+
from collections.abc import Iterable
15+
from pathlib import Path
16+
17+
from langchain_core.tools import BaseTool
18+
from pydantic import BaseModel, Field
19+
from uipath.runtime import Workspace
20+
21+
from uipath_langchain.agent.tools.base_uipath_structured_tool import (
22+
BaseUiPathStructuredTool,
23+
)
24+
25+
ALLOWED_BINARIES = {"uipath", "uip"}
26+
MAX_LOG_BYTES = 4000
27+
28+
# Classify as "internal" so traces label it (untyped default is "Integration").
29+
UIPATH_CLI_TOOL_NAME = "uipath_cli"
30+
UIPATH_CLI_TOOL_TYPE = "internal"
31+
32+
# Destructive verbs blocked by default.
33+
DEFAULT_DENIED_SUBCOMMANDS = frozenset(
34+
{
35+
"delete",
36+
"remove",
37+
"cancel",
38+
"uninstall",
39+
"unassign",
40+
"unlink",
41+
"unset",
42+
"clear",
43+
"rm",
44+
"del",
45+
"destroy",
46+
"purge",
47+
"drop",
48+
"revoke",
49+
"deactivate",
50+
"undeploy",
51+
"unpublish",
52+
"unregister",
53+
"unimport",
54+
"prune",
55+
"wipe",
56+
"reset",
57+
}
58+
)
59+
60+
__all__ = [
61+
"create_uipath_cli_tool",
62+
"DEFAULT_DENIED_SUBCOMMANDS",
63+
"UIPATH_CLI_TOOL_NAME",
64+
"UIPATH_CLI_TOOL_TYPE",
65+
]
66+
67+
_TOOL_DESCRIPTION = (
68+
"Run a `uipath` / `uip` command in a workspace subdirectory. Returns combined "
69+
"stdout/stderr. Only `uipath` and `uip` are allowed. Inherits the agent process "
70+
"environment, including `UIPATH_ACCESS_TOKEN` / `UIPATH_BASE_URL` for tenant auth."
71+
)
72+
73+
74+
class UiPathCliArgs(BaseModel):
75+
"""Arguments for ``uipath_cli``."""
76+
77+
command: str = Field(
78+
description=(
79+
"Full CLI command starting with `uipath` or `uip`, e.g. 'uipath pack' "
80+
"or 'uip solution publish dev'."
81+
),
82+
)
83+
subdir: str = Field(
84+
default="",
85+
description=(
86+
"Workspace-relative subdirectory to run in. Give each scaffolded "
87+
"project its own subdir. Defaults to the workspace root."
88+
),
89+
)
90+
91+
92+
def create_uipath_cli_tool(
93+
workspace: Workspace,
94+
timeout_sec: int = 120,
95+
denied_subcommands: Iterable[str] | None = None,
96+
) -> BaseTool:
97+
"""Build a ``uipath_cli`` tool jailed to ``workspace``.
98+
99+
Pass it via the ``tools`` argument of the advanced agent builders — inject it
100+
only for UiPath skills, not customer skills. ``denied_subcommands`` defaults to
101+
:data:`DEFAULT_DENIED_SUBCOMMANDS`; pass an empty iterable to allow everything.
102+
103+
The tool only reads ``workspace.path``; the workspace lifecycle (creation,
104+
cleanup) stays owned by the runtime.
105+
"""
106+
workspace_path = Path(workspace.path).expanduser().resolve()
107+
workspace_path.mkdir(parents=True, exist_ok=True)
108+
denied = frozenset(
109+
s.lower()
110+
for s in (
111+
DEFAULT_DENIED_SUBCOMMANDS
112+
if denied_subcommands is None
113+
else denied_subcommands
114+
)
115+
)
116+
117+
def run_uipath_cli(command: str, subdir: str = "") -> str:
118+
run_dir = workspace_path
119+
if subdir:
120+
candidate = (workspace_path / subdir).resolve()
121+
if not candidate.is_relative_to(workspace_path):
122+
return f"ERROR: subdir '{subdir}' escapes the workspace directory."
123+
candidate.mkdir(parents=True, exist_ok=True)
124+
run_dir = candidate
125+
126+
try:
127+
argv = shlex.split(command)
128+
except ValueError as e:
129+
return f"ERROR: could not parse command: {e}"
130+
131+
if not argv:
132+
return "ERROR: empty command"
133+
134+
binary = argv[0]
135+
# Reject any path-qualified binary (e.g. ./uip, /tmp/uip) so only the
136+
# UiPath CLI resolved from PATH runs, not a lookalike in the workspace.
137+
if os.path.basename(binary) != binary or binary not in ALLOWED_BINARIES:
138+
return (
139+
f"ERROR: '{binary}' is not allowed. Only these binaries are "
140+
f"permitted (by name, without a path): {sorted(ALLOWED_BINARIES)}"
141+
)
142+
143+
def is_denied(tok: str) -> bool:
144+
low = tok.lower()
145+
return any(low == d or low.startswith(f"{d}-") for d in denied)
146+
147+
blocked = next(
148+
(tok for tok in argv[1:] if not tok.startswith("-") and is_denied(tok)),
149+
None,
150+
)
151+
if blocked is not None:
152+
return (
153+
f"ERROR: subcommand '{blocked}' is not allowed. These destructive "
154+
f"subcommands are blocked: {sorted(denied)}"
155+
)
156+
157+
try:
158+
proc = subprocess.run(
159+
argv,
160+
cwd=str(run_dir),
161+
capture_output=True,
162+
text=True,
163+
timeout=timeout_sec,
164+
check=False,
165+
)
166+
except subprocess.TimeoutExpired:
167+
return f"ERROR: command timed out after {timeout_sec}s"
168+
except FileNotFoundError:
169+
return f"ERROR: '{binary}' not found on PATH. Install the UiPath CLI."
170+
171+
return (
172+
f"exit_code: {proc.returncode}\n"
173+
f"--- stdout ---\n{proc.stdout[:MAX_LOG_BYTES]}\n"
174+
f"--- stderr ---\n{proc.stderr[:MAX_LOG_BYTES]}"
175+
)
176+
177+
return BaseUiPathStructuredTool(
178+
name=UIPATH_CLI_TOOL_NAME,
179+
description=_TOOL_DESCRIPTION,
180+
args_schema=UiPathCliArgs,
181+
func=run_uipath_cli,
182+
# Declarative metadata mirrors the sibling internal tools; classify as
183+
# "internal" so traces label it (untyped default is "Integration").
184+
metadata={
185+
"tool_type": UIPATH_CLI_TOOL_TYPE,
186+
"display_name": UIPATH_CLI_TOOL_NAME,
187+
"args_schema": UiPathCliArgs,
188+
},
189+
)

0 commit comments

Comments
 (0)