diff --git a/pyproject.toml b/pyproject.toml index 2c740936d..fc9025be0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.14.9" +version = "0.14.10" description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -8,7 +8,7 @@ dependencies = [ "uipath>=2.13.7, <2.14.0", "uipath-core>=0.5.29, <0.6.0", "uipath-platform>=0.2.10, <0.3.0", - "uipath-runtime>=0.12.1, <0.13.0", + "uipath-runtime>=0.12.4, <0.13.0", "uipath-llm-client>=1.16.3, <1.17.0", "langgraph>=1.1.8, <2.0.0", "langchain-core>=1.2.27, <2.0.0", diff --git a/samples/README.md b/samples/README.md index ade8b2c1e..91b4464b8 100644 --- a/samples/README.md +++ b/samples/README.md @@ -6,6 +6,9 @@ This sample demonstrates a simple LangGraph agent that performs basic arithmetic ## [Chat agent](chat-agent) This sample shows how to build an AI assistant using LangGraph and Tavily search for movie research and recommendations. +## [Coded DeepAgent](coded-deepagent) +This sample demonstrates a task-mode coded DeepAgent using the UiPath runtime workspace contract, structured output, a tool, and a review subagent. + ## [Company research agent](company-research-agent) This sample demonstrates how to create an agent that researches companies and develops outreach strategies using web search capabilities. diff --git a/samples/coded-deepagent/README.md b/samples/coded-deepagent/README.md new file mode 100644 index 000000000..35bce71e5 --- /dev/null +++ b/samples/coded-deepagent/README.md @@ -0,0 +1,48 @@ +# Coded DeepAgent + +This sample demonstrates a task-mode coded agent built with +`uipath_langchain.deepagents.create_uipath_deep_agent`. + +The API follows the authoring surface of `deepagents.create_deep_agent`. UiPath +owns compile-time configuration, including the filesystem backend and +checkpointer. It marks the graph for the UiPath runtime, which creates and +hydrates a disk-backed workspace through job attachments. + +## What It Shows + +- Standard DeepAgents message input and structured output. +- A standard LangChain tool used by the main DeepAgent. +- A DeepAgents subagent used for risk review. +- Runtime-provided, attachment-hydrated filesystem workspace. + +## Files + +- `graph.py`: task-mode coded DeepAgent graph. +- `input.json`: sample input payload. +- `langgraph.json`: LangGraph entrypoint. +- `uipath.json`: UiPath task-mode runtime configuration. +- `pyproject.toml`: sample dependencies. +- `agent.mermaid`: conceptual view of the agent workflow. + +## Requirements + +- UiPath runtime credentials for `UiPathChat`. +- Access to the configured model, `gpt-5.4`. + +## Installation + +```bash +cd samples/coded-deepagent +uv sync +uv run uipath auth +``` + +## Usage + +```bash +uv run uipath init +uv run uipath run agent "$(cat input.json)" +``` + +The agent uses the filesystem tools supplied by the DeepAgents harness for its +working files and returns a structured launch brief. diff --git a/samples/coded-deepagent/agent.mermaid b/samples/coded-deepagent/agent.mermaid new file mode 100644 index 000000000..745399a23 --- /dev/null +++ b/samples/coded-deepagent/agent.mermaid @@ -0,0 +1,15 @@ +flowchart TB + __start__(__start__) + deep_agent(deep_agent) + tools([agent tools]) + risk_reviewer([risk_reviewer subagent]) + workspace[(runtime workspace)] + structured_output(structured output) + __end__(__end__) + + __start__ --> deep_agent + deep_agent <--> tools + deep_agent -. delegates review .-> risk_reviewer + deep_agent -. writes files .-> workspace + deep_agent --> structured_output + structured_output --> __end__ diff --git a/samples/coded-deepagent/graph.py b/samples/coded-deepagent/graph.py new file mode 100644 index 000000000..228437422 --- /dev/null +++ b/samples/coded-deepagent/graph.py @@ -0,0 +1,66 @@ +"""Task-mode coded DeepAgent using the UiPath DeepAgents API.""" + +from langchain_core.tools import tool +from pydantic import BaseModel + +from uipath_langchain.chat import UiPathChat +from uipath_langchain.deepagents import create_uipath_deep_agent + + +class BriefOutput(BaseModel): + """Structured launch brief returned by the agent.""" + + executive_summary: str + launch_plan: list[str] + risk_review: list[str] + + +@tool +def score_launch_readiness(audience: str, constraints: list[str]) -> str: + """Score launch readiness from simple deterministic planning signals.""" + score = 80 + if len(constraints) >= 3: + score -= 10 + if any("compliance" in item.lower() for item in constraints): + score -= 10 + if any("deadline" in item.lower() for item in constraints): + score -= 5 + return f"Launch readiness for {audience}: {max(score, 35)}/100" + + +MODEL = UiPathChat(model="gpt-5.4", temperature=0) + +RISK_REVIEWER_PROMPT = """You are a launch risk reviewer. +Review the proposed launch plan for practical delivery risks, compliance gaps, +unclear ownership, and missing follow-up work. Return concise findings that the +main agent can incorporate into the final brief.""" + +RISK_REVIEWER = { + "name": "risk_reviewer", + "description": "Reviews launch plans for execution risks and missing safeguards.", + "system_prompt": RISK_REVIEWER_PROMPT, + "model": MODEL, +} + + +SYSTEM_PROMPT = """You are a product launch planning agent. + +Use the planning tools provided by DeepAgents. Use score_launch_readiness to get +a deterministic readiness signal. Delegate a plan review to risk_reviewer before +finalizing the answer. + +Use the DeepAgents filesystem to write these working files before producing the +final answer: +- /launch/brief.md with the final launch brief +- /launch/risks.md with the risk review + +Return structured output matching the schema.""" + + +graph = create_uipath_deep_agent( + model=MODEL, + system_prompt=SYSTEM_PROMPT, + response_format=BriefOutput, + tools=[score_launch_readiness], + subagents=[RISK_REVIEWER], +) diff --git a/samples/coded-deepagent/input.json b/samples/coded-deepagent/input.json new file mode 100644 index 000000000..4fcfc4a95 --- /dev/null +++ b/samples/coded-deepagent/input.json @@ -0,0 +1,8 @@ +{ + "messages": [ + { + "type": "human", + "content": "Create a pilot launch plan for Invoice Exception Copilot for accounts payable operations managers at three enterprise customers. The security review must complete before external rollout, the deadline is six weeks away, and the support team needs enablement material." + } + ] +} diff --git a/samples/coded-deepagent/langgraph.json b/samples/coded-deepagent/langgraph.json new file mode 100644 index 000000000..c465a881b --- /dev/null +++ b/samples/coded-deepagent/langgraph.json @@ -0,0 +1,7 @@ +{ + "dependencies": ["."], + "graphs": { + "agent": "./graph.py:graph" + }, + "env": ".env" +} diff --git a/samples/coded-deepagent/pyproject.toml b/samples/coded-deepagent/pyproject.toml new file mode 100644 index 000000000..d6bd9ffa4 --- /dev/null +++ b/samples/coded-deepagent/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "coded-deepagent" +version = "0.0.1" +description = "Task-mode coded DeepAgent using the UiPath runtime workspace contract" +authors = [{ name = "UiPath", email = "support@uipath.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-langchain>=0.14.10, <0.15.0", + "uipath>=2.13.7, <2.14.0", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/samples/coded-deepagent/uipath.json b/samples/coded-deepagent/uipath.json new file mode 100644 index 000000000..30c00e944 --- /dev/null +++ b/samples/coded-deepagent/uipath.json @@ -0,0 +1,5 @@ +{ + "runtimeOptions": { + "isConversational": false + } +} diff --git a/src/uipath_langchain/deepagents/__init__.py b/src/uipath_langchain/deepagents/__init__.py new file mode 100644 index 000000000..4ef7df23d --- /dev/null +++ b/src/uipath_langchain/deepagents/__init__.py @@ -0,0 +1,13 @@ +"""UiPath DeepAgents integration.""" + +from typing import Any + +__all__ = ["create_uipath_deep_agent"] + + +def __getattr__(name: str) -> Any: + if name == "create_uipath_deep_agent": + from .agent import create_uipath_deep_agent + + return create_uipath_deep_agent + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/uipath_langchain/deepagents/agent.py b/src/uipath_langchain/deepagents/agent.py new file mode 100644 index 000000000..ba7693e91 --- /dev/null +++ b/src/uipath_langchain/deepagents/agent.py @@ -0,0 +1,66 @@ +"""UiPath-aware DeepAgents authoring API.""" + +from collections.abc import Callable, Sequence +from typing import Any + +from deepagents import ( + AsyncSubAgent, + CompiledSubAgent, + FilesystemPermission, + SubAgent, +) +from deepagents import create_deep_agent as _create_deep_agent +from langchain.agents import AgentState +from langchain.agents.middleware import AgentMiddleware, InterruptOnConfig +from langchain.agents.middleware.types import ResponseT +from langchain.agents.structured_output import ResponseFormat +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import SystemMessage +from langchain_core.tools import BaseTool +from langgraph.graph.state import CompiledStateGraph +from langgraph.typing import ContextT + +from .backend import _UiPathWorkspaceBackendFactory +from .metadata import mark_uipath_deep_agent + + +# This API intentionally omits DeepAgents' compile-time arguments. +# UiPath-compatible DeepAgents use the runtime-managed filesystem backend and +# checkpointer. Other compile-time settings are currently unsupported. +def create_uipath_deep_agent( + model: str | BaseChatModel | None = None, + tools: Sequence[BaseTool | Callable[..., Any] | dict[str, Any]] | None = None, + *, + system_prompt: str | SystemMessage | None = None, + middleware: Sequence[AgentMiddleware] = (), + subagents: Sequence[SubAgent | CompiledSubAgent | AsyncSubAgent] | None = None, + skills: list[str] | None = None, + memory: list[str] | None = None, + permissions: list[FilesystemPermission] | None = None, + interrupt_on: dict[str, bool | InterruptOnConfig] | None = None, + response_format: ResponseFormat[ResponseT] + | type[ResponseT] + | dict[str, Any] + | None = None, + context_schema: type[ContextT] | None = None, +) -> CompiledStateGraph[AgentState[ResponseT], ContextT, Any, Any]: + """Create a DeepAgent backed by the UiPath runtime workspace. + + Parameters match ``deepagents.create_deep_agent`` and are forwarded unchanged, + except that UiPath owns compile-time configuration. + """ + graph = _create_deep_agent( + model=model, + tools=tools, + system_prompt=system_prompt, + middleware=middleware, + subagents=subagents, + skills=skills, + memory=memory, + permissions=permissions, + backend=_UiPathWorkspaceBackendFactory(), + interrupt_on=interrupt_on, + response_format=response_format, + context_schema=context_schema, + ) + return mark_uipath_deep_agent(graph) diff --git a/src/uipath_langchain/deepagents/backend.py b/src/uipath_langchain/deepagents/backend.py new file mode 100644 index 000000000..3ff6f3565 --- /dev/null +++ b/src/uipath_langchain/deepagents/backend.py @@ -0,0 +1,24 @@ +"""Workspace backend helpers for UiPath DeepAgents.""" + +from deepagents.backends import FilesystemBackend +from langchain.tools import ToolRuntime +from langchain_core.runnables import RunnableConfig +from langgraph.config import get_config + +from uipath_langchain.runtime._workspace import get_workspace_path + + +class _UiPathWorkspaceBackendFactory: + """DeepAgents backend factory resolved from UiPath runtime config.""" + + def __call__(self, runtime: ToolRuntime) -> FilesystemBackend: + config: RunnableConfig | None = getattr(runtime, "config", None) + if config is None: + try: + config = get_config() + except RuntimeError: + config = {} + return FilesystemBackend( + root_dir=get_workspace_path(config), + virtual_mode=True, + ) diff --git a/src/uipath_langchain/deepagents/metadata.py b/src/uipath_langchain/deepagents/metadata.py new file mode 100644 index 000000000..421836a82 --- /dev/null +++ b/src/uipath_langchain/deepagents/metadata.py @@ -0,0 +1,39 @@ +"""Runtime marker for UiPath-created DeepAgents graphs.""" + +from typing import Any, TypeVar + +from langgraph.graph.state import CompiledStateGraph + +_UIPATH_INTEGRATION_METADATA_KEY = "uipath_integration" +_UIPATH_DEEPAGENTS_INTEGRATION = "deepagents" + +CompiledGraphT = TypeVar( + "CompiledGraphT", + bound=CompiledStateGraph[Any, Any, Any, Any], +) + + +def mark_uipath_deep_agent( + graph: CompiledGraphT, +) -> CompiledGraphT: + """Return a graph copy with explicit UiPath DeepAgents runtime metadata.""" + return graph.with_config( + { + "metadata": { + _UIPATH_INTEGRATION_METADATA_KEY: _UIPATH_DEEPAGENTS_INTEGRATION, + } + } + ) + + +def is_uipath_deep_agent( + graph: CompiledStateGraph[Any, Any, Any, Any], +) -> bool: + """Return whether ``graph`` was built by the UiPath DeepAgents API.""" + config = getattr(graph, "config", None) or {} + metadata = config.get("metadata", {}) if isinstance(config, dict) else {} + if not isinstance(metadata, dict): + return False + return ( + metadata.get(_UIPATH_INTEGRATION_METADATA_KEY) == _UIPATH_DEEPAGENTS_INTEGRATION + ) diff --git a/src/uipath_langchain/runtime/_workspace.py b/src/uipath_langchain/runtime/_workspace.py new file mode 100644 index 000000000..4cfcbb5f3 --- /dev/null +++ b/src/uipath_langchain/runtime/_workspace.py @@ -0,0 +1,43 @@ +"""Internal workspace integration shared by runtime-backed agents.""" + +from pathlib import Path +from typing import Any, NotRequired, TypedDict + +from langchain_core.runnables import RunnableConfig + +WORKSPACE_PATH_CONFIG_KEY = "uipath_workspace_path" + + +class _UiPathGraphConfigurable(TypedDict): + """UiPath-owned values injected into LangGraph's configurable payload.""" + + thread_id: str + uipath_workspace_path: NotRequired[str] + + +def create_graph_configurable( + *, + thread_id: str, + workspace_path: Path | None, +) -> dict[str, Any]: + """Create the UiPath-owned portion of LangGraph's configurable payload.""" + configurable: _UiPathGraphConfigurable = {"thread_id": thread_id} + if workspace_path is not None: + configurable["uipath_workspace_path"] = str(workspace_path) + return dict(configurable) + + +def get_workspace_path(config: RunnableConfig) -> Path: + """Decode and validate the UiPath-managed workspace path from graph config.""" + configurable = config.get("configurable") + workspace_path = ( + configurable.get(WORKSPACE_PATH_CONFIG_KEY) + if isinstance(configurable, dict) + else None + ) + if not isinstance(workspace_path, str) or not workspace_path: + raise RuntimeError( + "UiPath DeepAgents workspace path is unavailable. Run graphs created " + "by create_uipath_deep_agent through the UiPath runtime." + ) + return Path(workspace_path) diff --git a/src/uipath_langchain/runtime/factory.py b/src/uipath_langchain/runtime/factory.py index 9d16137b5..0f0cf554a 100644 --- a/src/uipath_langchain/runtime/factory.py +++ b/src/uipath_langchain/runtime/factory.py @@ -1,5 +1,8 @@ import asyncio +import hashlib import os +import shutil +from pathlib import Path from typing import Any, AsyncContextManager from langchain_core.callbacks import BaseCallbackHandler @@ -12,19 +15,28 @@ ) from uipath.core.adapters import EvaluatorProtocol from uipath.core.tracing import UiPathSpanUtils, UiPathTraceManager +from uipath.platform import UiPath from uipath.platform.resume_triggers import ( UiPathResumeTriggerHandler, ) from uipath.runtime import ( + HydrationPolicy, + HydrationRuntime, UiPathResumableRuntime, UiPathRuntimeContext, UiPathRuntimeFactorySettings, UiPathRuntimeProtocol, UiPathRuntimeStorageProtocol, + Workspace, + WorkspaceHydrator, + WorkspaceRegistryStore, ) from uipath.runtime.errors import UiPathErrorCategory from uipath_langchain._tracing import _instrument_traceable_attributes +from uipath_langchain.deepagents.metadata import ( + is_uipath_deep_agent, +) from uipath_langchain.governance import GovernanceCallbackHandler from uipath_langchain.runtime.config import LangGraphConfig from uipath_langchain.runtime.errors import LangGraphErrorCode, LangGraphRuntimeError @@ -195,8 +207,9 @@ async def _compile_graph( The compiled StateGraph """ builder = graph.builder if isinstance(graph, CompiledStateGraph) else graph - - return builder.compile(checkpointer=memory) + compiled = builder.compile(checkpointer=memory) + bound_config = getattr(graph, "config", None) + return compiled.with_config(bound_config) if bound_config else compiled async def _resolve_and_compile_graph( self, entrypoint: str, memory: AsyncSqliteSaver, **kwargs @@ -220,7 +233,6 @@ async def _resolve_and_compile_graph( return self._graph_cache[entrypoint] loaded_graph = await self._load_graph(entrypoint, **kwargs) - compiled_graph = await self._compile_graph(loaded_graph, memory) self._graph_cache[entrypoint] = compiled_graph @@ -300,21 +312,70 @@ async def _create_runtime_instance( else None ) + workspace = ( + self._create_deep_agent_workspace(runtime_id) + if is_uipath_deep_agent(compiled_graph) + else None + ) + base_runtime = UiPathLangGraphRuntime( graph=compiled_graph, runtime_id=runtime_id, entrypoint=entrypoint, callbacks=callbacks, storage=storage, + workspace_path=workspace.path if workspace is not None else None, ) - return UiPathResumableRuntime( + resumable_runtime: UiPathRuntimeProtocol = UiPathResumableRuntime( delegate=base_runtime, storage=storage, trigger_manager=trigger_manager, runtime_id=runtime_id, ) + if workspace is None: + return resumable_runtime + + def create_hydrator() -> WorkspaceHydrator: + sdk = UiPath() + return WorkspaceHydrator( + workspace_path=workspace.path, + attachments=sdk.attachments, + jobs=sdk.jobs, + current_job_key=self.context.job_id, + folder_key=self.context.folder_key, + ) + + return HydrationRuntime( + delegate=resumable_runtime, + workspace=workspace, + hydrator_factory=create_hydrator, + registry_store=WorkspaceRegistryStore( + storage, + runtime_id, + ), + policy=self._select_deep_agent_hydration_policy(), + ) + + def _select_deep_agent_hydration_policy(self) -> HydrationPolicy: + """Select the UiPath-owned hydration lifecycle for this execution mode.""" + return HydrationPolicy.SUSPEND_OR_SUCCESS + + def _create_deep_agent_workspace( + self, + runtime_id: str, + ) -> Workspace: + """Create a clean, path-safe workspace for one runtime instance.""" + base_dir = ( + Path(self.context.runtime_dir or "__uipath") / "workspaces" + ).resolve() + workspace_id = hashlib.sha256(runtime_id.encode("utf-8")).hexdigest() + workspace_path = base_dir / workspace_id + if workspace_path.exists(): + shutil.rmtree(workspace_path) + return Workspace.create(workspace_path, cleanup=True) + async def new_runtime( self, entrypoint: str, diff --git a/src/uipath_langchain/runtime/runtime.py b/src/uipath_langchain/runtime/runtime.py index 04657b1e7..c305670ff 100644 --- a/src/uipath_langchain/runtime/runtime.py +++ b/src/uipath_langchain/runtime/runtime.py @@ -2,6 +2,7 @@ import logging import os from collections.abc import Iterator +from pathlib import Path from typing import Any, AsyncGenerator from uuid import uuid4 @@ -42,6 +43,7 @@ from uipath_langchain.runtime.schema import get_entrypoints_schema, get_graph_schema from ._serialize import serialize_output +from ._workspace import create_graph_configurable # Guarded import: ReferenceContext was added to uipath.tracing in a later release. # Older installed packages lack it; the noop shim keeps the runtime loadable while @@ -91,6 +93,7 @@ def __init__( entrypoint: str | None = None, callbacks: list[BaseCallbackHandler] | None = None, storage: UiPathRuntimeStorageProtocol | None = None, + workspace_path: Path | None = None, ): """ Initialize the runtime. @@ -99,11 +102,15 @@ def __init__( graph: The CompiledStateGraph to execute runtime_id: Unique identifier for this runtime instance entrypoint: Optional entrypoint name (for schema generation) + callbacks: Optional callbacks passed to graph execution + storage: Optional runtime storage used by the chat mapper + workspace_path: Optional runtime-managed physical workspace """ self.graph: CompiledStateGraph[Any, Any, Any, Any] = graph self.runtime_id: str = runtime_id or "default" self.entrypoint: str | None = entrypoint self.callbacks: list[BaseCallbackHandler] = callbacks or [] + self.workspace_path: Path | None = workspace_path self.chat = UiPathChatMessagesMapper(self.runtime_id, storage) self.chat.tools_requiring_confirmation = self._get_tool_confirmation_info() self.chat.client_side_tools = self._get_client_side_tools() @@ -349,7 +356,10 @@ def create_runtime_error(self, e: Exception) -> UiPathBaseRuntimeError: def _get_graph_config(self) -> RunnableConfig: """Build graph execution configuration.""" graph_config: RunnableConfig = { - "configurable": {"thread_id": self.runtime_id}, + "configurable": create_graph_configurable( + thread_id=self.runtime_id, + workspace_path=self.workspace_path, + ), "callbacks": self.callbacks, } diff --git a/tests/deepagents/__init__.py b/tests/deepagents/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/deepagents/test_agent.py b/tests/deepagents/test_agent.py new file mode 100644 index 000000000..2df61e4b9 --- /dev/null +++ b/tests/deepagents/test_agent.py @@ -0,0 +1,68 @@ +from unittest.mock import MagicMock, patch + +from langgraph.graph import END, START, StateGraph +from typing_extensions import TypedDict + +from uipath_langchain.deepagents import create_uipath_deep_agent +from uipath_langchain.deepagents.backend import _UiPathWorkspaceBackendFactory +from uipath_langchain.deepagents.metadata import is_uipath_deep_agent + + +class _State(TypedDict): + value: str + + +def _compiled_graph(): + builder = StateGraph(_State) + builder.add_node("noop", lambda state: state) + builder.add_edge(START, "noop") + builder.add_edge("noop", END) + return builder.compile() + + +def test_api_forwards_upstream_contract_with_uipath_backend() -> None: + compiled = _compiled_graph() + model = MagicMock() + tool = MagicMock() + middleware = MagicMock() + subagent = MagicMock() + response_format = MagicMock() + context_schema = MagicMock() + + with patch( + "uipath_langchain.deepagents.agent._create_deep_agent", + return_value=compiled, + ) as upstream: + graph = create_uipath_deep_agent( + model=model, + tools=[tool], + system_prompt="system", + middleware=[middleware], + subagents=[subagent], + skills=["/skills/"], + memory=["/memory/AGENTS.md"], + permissions=[], + interrupt_on={"write_file": True}, + response_format=response_format, + context_schema=context_schema, + ) + + kwargs = upstream.call_args.kwargs + assert kwargs["model"] is model + assert kwargs["tools"] == [tool] + assert kwargs["system_prompt"] == "system" + assert kwargs["middleware"] == [middleware] + assert kwargs["subagents"] == [subagent] + assert kwargs["skills"] == ["/skills/"] + assert kwargs["memory"] == ["/memory/AGENTS.md"] + assert kwargs["permissions"] == [] + assert kwargs["interrupt_on"] == {"write_file": True} + assert kwargs["response_format"] is response_format + assert kwargs["context_schema"] is context_schema + assert "checkpointer" not in kwargs + assert "store" not in kwargs + assert "cache" not in kwargs + assert "debug" not in kwargs + assert "name" not in kwargs + assert isinstance(kwargs["backend"], _UiPathWorkspaceBackendFactory) + assert is_uipath_deep_agent(graph) diff --git a/tests/deepagents/test_backend.py b/tests/deepagents/test_backend.py new file mode 100644 index 000000000..80b96836c --- /dev/null +++ b/tests/deepagents/test_backend.py @@ -0,0 +1,80 @@ +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from deepagents.backends import FilesystemBackend +from langchain.tools import ToolRuntime +from langchain_core.runnables import RunnableConfig + +from uipath_langchain.deepagents.backend import _UiPathWorkspaceBackendFactory +from uipath_langchain.runtime._workspace import WORKSPACE_PATH_CONFIG_KEY + + +def _tool_runtime(config: RunnableConfig) -> ToolRuntime: + return ToolRuntime( + state={}, + context=None, + config=config, + stream_writer=lambda _: None, + tool_call_id=None, + store=None, + ) + + +def test_workspace_backend_factory_resolves_configured_workspace(tmp_path) -> None: + factory = _UiPathWorkspaceBackendFactory() + + backend = factory( + _tool_runtime({"configurable": {WORKSPACE_PATH_CONFIG_KEY: str(tmp_path)}}) + ) + + assert isinstance(backend, FilesystemBackend) + assert backend.cwd == tmp_path.resolve() + assert backend.virtual_mode is True + + +def test_workspace_backend_confines_file_access_to_workspace(tmp_path) -> None: + backend = _UiPathWorkspaceBackendFactory()( + _tool_runtime({"configurable": {WORKSPACE_PATH_CONFIG_KEY: str(tmp_path)}}) + ) + + result = backend.write("/launch/brief.md", "launch plan") + + assert result.error is None + assert (tmp_path / "launch" / "brief.md").read_text() == "launch plan" + with pytest.raises(ValueError, match="Path traversal not allowed"): + backend.write("../escape.md", "outside") + assert not (tmp_path.parent / "escape.md").exists() + + +def test_workspace_backend_factory_raises_when_config_missing() -> None: + factory = _UiPathWorkspaceBackendFactory() + + with pytest.raises(RuntimeError, match="workspace path is unavailable"): + factory(_tool_runtime({"configurable": {}})) + + +@pytest.mark.parametrize("workspace_path", [None, 42, ""]) +def test_workspace_backend_factory_rejects_invalid_workspace_path( + workspace_path: object, +) -> None: + factory = _UiPathWorkspaceBackendFactory() + + with pytest.raises(RuntimeError, match="workspace path is unavailable"): + factory( + _tool_runtime({"configurable": {WORKSPACE_PATH_CONFIG_KEY: workspace_path}}) + ) + + +def test_workspace_backend_factory_uses_active_config_for_current_runtime( + tmp_path, +) -> None: + factory = _UiPathWorkspaceBackendFactory() + + with patch( + "uipath_langchain.deepagents.backend.get_config", + return_value={"configurable": {WORKSPACE_PATH_CONFIG_KEY: str(tmp_path)}}, + ): + backend = factory(SimpleNamespace()) # type: ignore[arg-type] + + assert backend.cwd == tmp_path.resolve() diff --git a/tests/deepagents/test_metadata.py b/tests/deepagents/test_metadata.py new file mode 100644 index 000000000..9fb5082a5 --- /dev/null +++ b/tests/deepagents/test_metadata.py @@ -0,0 +1,46 @@ +import inspect +from unittest.mock import MagicMock + +from deepagents import create_deep_agent +from langchain_core.language_models import BaseChatModel + +from uipath_langchain.deepagents import create_uipath_deep_agent +from uipath_langchain.deepagents.metadata import is_uipath_deep_agent + + +def _model() -> BaseChatModel: + model = MagicMock(spec=BaseChatModel) + model.profile = None + return model + + +def test_uipath_api_matches_upstream_authoring_parameters() -> None: + upstream = inspect.signature(create_deep_agent).parameters + uipath = inspect.signature(create_uipath_deep_agent).parameters + runtime_owned = {"backend", "checkpointer", "store", "cache", "debug", "name"} + + assert list(uipath) == [name for name in upstream if name not in runtime_owned] + for name, parameter in uipath.items(): + assert parameter.kind == upstream[name].kind + assert parameter.default == upstream[name].default + + +def test_uipath_deepagent_has_explicit_runtime_metadata() -> None: + graph = create_uipath_deep_agent(model=_model()) + + assert is_uipath_deep_agent(graph) + assert graph.config["metadata"]["uipath_integration"] == "deepagents" + assert graph.config["metadata"]["ls_integration"] == "deepagents" + + +def test_standard_deepagent_is_not_treated_as_uipath_deepagent() -> None: + graph = create_deep_agent(model=_model()) + + assert not is_uipath_deep_agent(graph) + + +def test_deepagent_detection_tolerates_malformed_metadata() -> None: + graph = MagicMock() + graph.config = {"metadata": None} + + assert not is_uipath_deep_agent(graph) diff --git a/tests/deepagents/test_runtime_config.py b/tests/deepagents/test_runtime_config.py new file mode 100644 index 000000000..8634b42fb --- /dev/null +++ b/tests/deepagents/test_runtime_config.py @@ -0,0 +1,27 @@ +from pathlib import Path +from unittest.mock import MagicMock + +from uipath_langchain.runtime._workspace import WORKSPACE_PATH_CONFIG_KEY +from uipath_langchain.runtime.runtime import UiPathLangGraphRuntime + + +def test_runtime_adds_workspace_path_to_graph_config(tmp_path) -> None: + runtime = UiPathLangGraphRuntime( + graph=MagicMock(), + runtime_id="runtime-1", + workspace_path=Path(tmp_path), + ) + + config = runtime._get_graph_config() + + assert config["configurable"]["thread_id"] == "runtime-1" + assert config["configurable"][WORKSPACE_PATH_CONFIG_KEY] == str(tmp_path) + + +def test_runtime_without_workspace_has_only_thread_config() -> None: + runtime = UiPathLangGraphRuntime( + graph=MagicMock(), + runtime_id="runtime-1", + ) + + assert runtime._get_graph_config()["configurable"] == {"thread_id": "runtime-1"} diff --git a/tests/deepagents/test_runtime_factory.py b/tests/deepagents/test_runtime_factory.py new file mode 100644 index 000000000..1961faa6f --- /dev/null +++ b/tests/deepagents/test_runtime_factory.py @@ -0,0 +1,286 @@ +import hashlib +from pathlib import Path +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import UUID, uuid4 + +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver +from langgraph.graph import END, START, StateGraph +from typing_extensions import TypedDict +from uipath.runtime import ( + HydrationPolicy, + HydrationRuntime, + UiPathResumableRuntime, + UiPathRuntimeContext, + UiPathRuntimeResult, + WorkspaceHydrator, + WorkspaceRegistryStore, +) + +from uipath_langchain.deepagents.metadata import ( + is_uipath_deep_agent, + mark_uipath_deep_agent, +) +from uipath_langchain.runtime.factory import UiPathLangGraphRuntimeFactory +from uipath_langchain.runtime.runtime import UiPathLangGraphRuntime + + +class _State(TypedDict): + value: str + + +class _AttachmentStore: + def __init__(self) -> None: + self.files: dict[UUID, bytes] = {} + + async def upload_async( + self, + *, + name: str, + content: str | bytes | None = None, + source_path: str | None = None, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> UUID: + key = uuid4() + if source_path is not None: + payload = Path(source_path).read_bytes() + elif isinstance(content, str): + payload = content.encode() + elif content is not None: + payload = content + else: + raise ValueError("Expected attachment content or source path") + self.files[key] = payload + return key + + async def download_async( + self, + *, + key: UUID, + destination_path: str, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> str: + Path(destination_path).write_bytes(self.files[key]) + return destination_path + + +class _RegistryStore: + def __init__(self) -> None: + self.value: dict[str, dict[str, Any]] = {} + + async def load(self) -> dict[str, dict[str, Any]]: + return self.value.copy() + + async def save(self, registry: dict[str, dict[str, Any]]) -> None: + self.value = registry.copy() + + +def _build_graph() -> StateGraph[Any, Any, Any]: + graph = StateGraph(_State) + graph.add_node("noop", lambda state: state) + graph.add_edge(START, "noop") + graph.add_edge("noop", END) + return graph + + +def _test_checkpointer() -> AsyncSqliteSaver: + return cast(AsyncSqliteSaver, InMemorySaver()) + + +async def test_runtime_recompilation_preserves_bound_graph_config(tmp_path) -> None: + context = UiPathRuntimeContext(runtime_dir=str(tmp_path), state_file="state.db") + factory = UiPathLangGraphRuntimeFactory(context) + loaded = mark_uipath_deep_agent( + _build_graph() + .compile() + .with_config( + { + "metadata": {"ls_integration": "deepagents"}, + "recursion_limit": 9999, + "tags": ["bound-tag"], + } + ) + ) + + result = await factory._compile_graph(loaded, _test_checkpointer()) + + assert is_uipath_deep_agent(result) + config = result.config + assert config is not None + assert config["recursion_limit"] == 9999 + assert config["tags"] == ["bound-tag"] + assert config["metadata"] == { + "ls_integration": "deepagents", + "uipath_integration": "deepagents", + } + + +async def test_resolved_deep_agent_graph_keeps_marker_when_cached(tmp_path) -> None: + context = UiPathRuntimeContext(runtime_dir=str(tmp_path), state_file="state.db") + factory = UiPathLangGraphRuntimeFactory(context) + loaded = mark_uipath_deep_agent(_build_graph().compile()) + + with patch.object(factory, "_load_graph", AsyncMock(return_value=loaded)) as load: + first = await factory._resolve_and_compile_graph("agent", _test_checkpointer()) + second = await factory._resolve_and_compile_graph("agent", _test_checkpointer()) + + assert second is first + assert is_uipath_deep_agent(second) + load.assert_awaited_once() + + +async def test_marked_deep_agent_runtime_uses_hydrated_workspace(tmp_path) -> None: + context = UiPathRuntimeContext( + runtime_dir=str(tmp_path), + state_file="state.db", + job_id="job-1", + folder_key="folder-1", + ) + factory = UiPathLangGraphRuntimeFactory(context) + compiled = mark_uipath_deep_agent(_build_graph().compile()) + sdk = MagicMock() + sdk.attachments.download_async = AsyncMock(return_value="downloaded") + + with ( + patch( + "uipath_langchain.runtime.factory.UiPath", return_value=sdk + ) as sdk_constructor, + patch.object(factory, "_get_memory", AsyncMock(return_value=MagicMock())), + ): + runtime = await factory._create_runtime_instance( + compiled_graph=compiled, + runtime_id="runtime-1", + entrypoint="agent", + ) + assert isinstance(runtime, HydrationRuntime) + await runtime.get_schema() + sdk_constructor.assert_not_called() + assert runtime.hydrator is None + hydrator = runtime._get_hydrator() + sdk_constructor.assert_called_once_with() + + assert runtime.policy == HydrationPolicy.SUSPEND_OR_SUCCESS + assert runtime.workspace.path.parent == tmp_path / "workspaces" + assert runtime.workspace.path.name != "runtime-1" + + resumable_runtime = runtime.delegate + assert isinstance(resumable_runtime, UiPathResumableRuntime) + langgraph_runtime = resumable_runtime.delegate + assert isinstance(langgraph_runtime, UiPathLangGraphRuntime) + assert langgraph_runtime.workspace_path == runtime.workspace.path + + assert hydrator.workspace_path == runtime.workspace.path + assert hydrator.attachments is sdk.attachments + assert hydrator.jobs is sdk.jobs + assert hydrator.current_job_key == "job-1" + assert hydrator.folder_key == "folder-1" + assert hydrator.attachment_prefix == ".uipath-workspace" + assert runtime.registry_store.runtime_id == "runtime-1" + + +async def test_workspace_is_persisted_and_rehydrated_before_resumed_execution( + tmp_path, +) -> None: + context = UiPathRuntimeContext(runtime_dir=str(tmp_path), state_file="state.db") + factory = UiPathLangGraphRuntimeFactory(context) + compiled = mark_uipath_deep_agent(_build_graph().compile()) + attachments = _AttachmentStore() + registry = _RegistryStore() + + with patch.object(factory, "_get_memory", AsyncMock(return_value=MagicMock())): + first = await factory._create_runtime_instance( + compiled_graph=compiled, + runtime_id="runtime-1", + entrypoint="agent", + ) + assert isinstance(first, HydrationRuntime) + first.hydrator = WorkspaceHydrator( + workspace_path=first.workspace.path, + attachments=attachments, + ) + first.registry_store = cast(WorkspaceRegistryStore, registry) + workspace_file = first.workspace.path / "notes" / "plan.txt" + + async def write_workspace(*args, **kwargs) -> UiPathRuntimeResult: + workspace_file.parent.mkdir(parents=True) + workspace_file.write_text("persisted plan") + return UiPathRuntimeResult() + + with patch.object(first.delegate, "execute", side_effect=write_workspace): + await first.execute({}) + + assert list(attachments.files.values()) == [b"persisted plan"] + + resumed = await factory._create_runtime_instance( + compiled_graph=compiled, + runtime_id="runtime-1", + entrypoint="agent", + ) + assert isinstance(resumed, HydrationRuntime) + resumed.hydrator = WorkspaceHydrator( + workspace_path=resumed.workspace.path, + attachments=attachments, + ) + resumed.registry_store = cast(WorkspaceRegistryStore, registry) + resumed_file = resumed.workspace.path / "notes" / "plan.txt" + assert not resumed_file.exists() + + async def assert_hydrated(*args, **kwargs) -> UiPathRuntimeResult: + assert resumed_file.read_text() == "persisted plan" + return UiPathRuntimeResult() + + with patch.object(resumed.delegate, "execute", side_effect=assert_hydrated): + await resumed.execute({}) + + await resumed.workspace.dispose() + + +async def test_plain_graph_runtime_is_not_hydrated(tmp_path) -> None: + context = UiPathRuntimeContext(runtime_dir=str(tmp_path), state_file="state.db") + factory = UiPathLangGraphRuntimeFactory(context) + + with ( + patch( + "uipath_langchain.runtime.factory.UiPath", + side_effect=AssertionError("UiPath SDK must remain lazy"), + ), + patch.object(factory, "_get_memory", AsyncMock(return_value=MagicMock())), + ): + runtime = await factory._create_runtime_instance( + compiled_graph=_build_graph().compile(), + runtime_id="runtime-1", + entrypoint="agent", + ) + + assert not isinstance(runtime, HydrationRuntime) + assert isinstance(runtime, UiPathResumableRuntime) + langgraph_runtime = runtime.delegate + assert isinstance(langgraph_runtime, UiPathLangGraphRuntime) + assert langgraph_runtime.workspace_path is None + assert not (tmp_path / "workspaces").exists() + + +def test_deep_agent_workspace_is_deterministic_and_path_safe(tmp_path) -> None: + context = UiPathRuntimeContext(runtime_dir=str(tmp_path), state_file="state.db") + factory = UiPathLangGraphRuntimeFactory(context) + + workspace = factory._create_deep_agent_workspace("../../outside") + + assert workspace.path.parent == tmp_path / "workspaces" + assert workspace.path.name == hashlib.sha256(b"../../outside").hexdigest() + + +def test_deep_agent_workspace_clears_files_left_by_previous_process(tmp_path) -> None: + context = UiPathRuntimeContext(runtime_dir=str(tmp_path), state_file="state.db") + factory = UiPathLangGraphRuntimeFactory(context) + workspace = factory._create_deep_agent_workspace("runtime-1") + stale_file = workspace.path / "stale.txt" + stale_file.write_text("stale") + + recreated = factory._create_deep_agent_workspace("runtime-1") + + assert recreated.path == workspace.path + assert not stale_file.exists() diff --git a/uv.lock b/uv.lock index 4d682cc5c..d3fdf456b 100644 --- a/uv.lock +++ b/uv.lock @@ -4498,7 +4498,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.14.9" +version = "0.14.10" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -4589,7 +4589,7 @@ requires-dist = [ { name = "uipath-langchain-client", extras = ["vertexai"], marker = "extra == 'vertex'", specifier = ">=1.16.1,<1.17.0" }, { name = "uipath-llm-client", specifier = ">=1.16.3,<1.17.0" }, { name = "uipath-platform", specifier = ">=0.2.10,<0.3.0" }, - { name = "uipath-runtime", specifier = ">=0.12.1,<0.13.0" }, + { name = "uipath-runtime", specifier = ">=0.12.4,<0.13.0" }, ] provides-extras = ["anthropic", "vertex", "bedrock", "fireworks", "all"] @@ -4689,16 +4689,16 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.12.2" +version = "0.12.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "chardet" }, { name = "uipath-core" }, { name = "vadersentiment" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/eb/1638b8307c9305527a449664c95a03a2af1bfaf1bd150819fb882ef71415/uipath_runtime-0.12.2.tar.gz", hash = "sha256:0d1f56f41add0d932bbe0c975d473ec27a86c58e14e9aa745d6501356c51284a", size = 235789, upload-time = "2026-07-07T11:02:12.599Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/4a/1ca6888f6bfe1d72abd3565217f542765d78cdb73aeaa7e8abf6e7487c82/uipath_runtime-0.12.4.tar.gz", hash = "sha256:6373056218dc505f52e9e90b7ed2034e7cca56b773f254ef5d51375f340f9638", size = 236504, upload-time = "2026-07-17T09:04:13.337Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/7a/9042e41a71726386d6f7673f843decd5405daff0787bfe127a8ac15abaa4/uipath_runtime-0.12.2-py3-none-any.whl", hash = "sha256:8faa88ac0af208b48f08abfff11530ae0279f1e88a12e9d2935f7f74111ebde1", size = 92087, upload-time = "2026-07-07T11:02:11.221Z" }, + { url = "https://files.pythonhosted.org/packages/08/15/50625aed5a8f2c788bd4153085c308e465618462c27434c00ec752fc1ae9/uipath_runtime-0.12.4-py3-none-any.whl", hash = "sha256:dc7b8c5c280322e27789a98508b1235a1fd4db210a99baabe538d1d8701c8b5c", size = 92351, upload-time = "2026-07-17T09:04:11.886Z" }, ] [[package]]