From 80f8f58de7d568f7a4420046c61fb5ed14519a08 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Thu, 23 Jul 2026 10:03:38 +0300 Subject: [PATCH] feat: support coded deepagents runtime --- pyproject.toml | 4 +- samples/README.md | 7 + samples/coded-deepagent-node/README.md | 40 ++ samples/coded-deepagent-node/graph.py | 83 ++++ samples/coded-deepagent-node/input.json | 3 + samples/coded-deepagent-node/langgraph.json | 7 + samples/coded-deepagent-node/pyproject.toml | 17 + samples/coded-deepagent-node/uipath.json | 6 + samples/coded-deepagent/README.md | 53 +++ samples/coded-deepagent/agent.mermaid | 15 + samples/coded-deepagent/graph.py | 66 ++++ samples/coded-deepagent/input.json | 8 + samples/coded-deepagent/langgraph.json | 7 + samples/coded-deepagent/pyproject.toml | 14 + samples/coded-deepagent/uipath.json | 5 + src/uipath_langchain/deepagents/__init__.py | 17 + src/uipath_langchain/deepagents/agent.py | 63 ++++ src/uipath_langchain/deepagents/backend.py | 24 ++ src/uipath_langchain/deepagents/metadata.py | 76 ++++ src/uipath_langchain/runtime/_workspace.py | 43 +++ src/uipath_langchain/runtime/factory.py | 121 +++++- src/uipath_langchain/runtime/runtime.py | 12 +- tests/deepagents/__init__.py | 0 tests/deepagents/test_agent.py | 67 ++++ tests/deepagents/test_backend.py | 80 ++++ tests/deepagents/test_metadata.py | 76 ++++ tests/deepagents/test_runtime_config.py | 27 ++ tests/deepagents/test_runtime_factory.py | 396 ++++++++++++++++++++ uv.lock | 11 +- 29 files changed, 1335 insertions(+), 13 deletions(-) create mode 100644 samples/coded-deepagent-node/README.md create mode 100644 samples/coded-deepagent-node/graph.py create mode 100644 samples/coded-deepagent-node/input.json create mode 100644 samples/coded-deepagent-node/langgraph.json create mode 100644 samples/coded-deepagent-node/pyproject.toml create mode 100644 samples/coded-deepagent-node/uipath.json create mode 100644 samples/coded-deepagent/README.md create mode 100644 samples/coded-deepagent/agent.mermaid create mode 100644 samples/coded-deepagent/graph.py create mode 100644 samples/coded-deepagent/input.json create mode 100644 samples/coded-deepagent/langgraph.json create mode 100644 samples/coded-deepagent/pyproject.toml create mode 100644 samples/coded-deepagent/uipath.json create mode 100644 src/uipath_langchain/deepagents/__init__.py create mode 100644 src/uipath_langchain/deepagents/agent.py create mode 100644 src/uipath_langchain/deepagents/backend.py create mode 100644 src/uipath_langchain/deepagents/metadata.py create mode 100644 src/uipath_langchain/runtime/_workspace.py create mode 100644 tests/deepagents/__init__.py create mode 100644 tests/deepagents/test_agent.py create mode 100644 tests/deepagents/test_backend.py create mode 100644 tests/deepagents/test_metadata.py create mode 100644 tests/deepagents/test_runtime_config.py create mode 100644 tests/deepagents/test_runtime_factory.py diff --git a/pyproject.toml b/pyproject.toml index 92290ce6f..f6a256559 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath-langchain" -version = "0.14.15" +version = "0.15.0" 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" dependencies = [ "uipath>=2.13.7, <2.14.0", "uipath-core>=0.5.29, <0.6.0", - "uipath-platform>=0.2.12, <0.3.0", + "uipath-platform>=0.2.13, <0.3.0", "uipath-runtime>=0.12.5, <0.13.0", "uipath-llm-client>=1.17.1, <1.18.0", "langgraph>=1.1.8, <2.0.0", diff --git a/samples/README.md b/samples/README.md index ade8b2c1e..f9d8d64be 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. @@ -44,3 +47,7 @@ This sample demonstrates automatic classification of support tickets into catego ## [Wait until timeout agent](wait-until-timeout-agent) This sample demonstrates waiting for whichever happens first: a child UiPath process completes, or a timer expires. +## [Coded DeepAgent Node](coded-deepagent-node) + +[`coded-deepagent-node`](coded-deepagent-node/) demonstrates a parent LangGraph +workflow that passes a structured request to an embedded coded DeepAgent node. diff --git a/samples/coded-deepagent-node/README.md b/samples/coded-deepagent-node/README.md new file mode 100644 index 000000000..6f2f50868 --- /dev/null +++ b/samples/coded-deepagent-node/README.md @@ -0,0 +1,40 @@ +# Coded DeepAgent Node + +This sample embeds a coded DeepAgent as one node in a larger LangGraph workflow. +The parent graph accepts a structured `request`, converts it to a human message, +then delegates launch planning to the agent node. + +The compiled parent graph is explicitly marked with +`with_uipath_managed_workspace`. This declares the requirement at the runtime +entrypoint, so it remains reliable when the agent node is wrapped in other +LangChain runnables. + +## Requirements + +- Python 3.11+ +- UiPath runtime credentials for `UiPathAzureChatOpenAI` +- Access to the configured OpenAI model, `gpt-5.4` + +## Installation + +```bash +cd samples/coded-deepagent-node +uv sync +uv run uipath auth +``` + +Managed workspace hydration is opt-in. Enable it before running this sample: + +```bash +export UIPATH_FEATURE_DeepAgentsWorkspaceHydration=true +``` + +## Usage + +```bash +uv run uipath init +uv run uipath run agent "$(cat input.json)" +``` + +The planner writes `/launch/plan.md` to its managed workspace before returning +the plan to the parent workflow. diff --git a/samples/coded-deepagent-node/graph.py b/samples/coded-deepagent-node/graph.py new file mode 100644 index 000000000..30183cdb0 --- /dev/null +++ b/samples/coded-deepagent-node/graph.py @@ -0,0 +1,83 @@ +"""Parent LangGraph workflow with an embedded UiPath DeepAgent node.""" + +from typing import Annotated + +from langchain_core.messages import AnyMessage, HumanMessage +from langchain_core.tools import tool +from langgraph.graph import END, START, StateGraph +from langgraph.graph.message import add_messages +from typing_extensions import TypedDict + +from uipath_langchain.chat import UiPathAzureChatOpenAI +from uipath_langchain.deepagents import ( + create_uipath_deep_agent, + with_uipath_managed_workspace, +) + + +class LaunchRequest(TypedDict): + """Structured input accepted by the parent workflow.""" + + request: str + + +class LaunchWorkflowState(LaunchRequest): + """State owned by the parent workflow around the planner node.""" + + messages: Annotated[list[AnyMessage], add_messages] + preflight_complete: bool + workflow_complete: bool + + +@tool +def score_launch_readiness(audience: str, constraints: list[str]) -> str: + """Return a deterministic launch-readiness score for a target audience.""" + 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 = UiPathAzureChatOpenAI(model="gpt-5.4", temperature=0) + +PLANNER_PROMPT = """You are the launch-planning node in a larger workflow. + +Use score_launch_readiness to assess the requested launch. Use the provided +filesystem tools to write /launch/plan.md before giving a concise plan with the +readiness assessment, milestones, risks, and owners. +""" + +launch_planner = create_uipath_deep_agent( + model=MODEL, + system_prompt=PLANNER_PROMPT, + tools=[score_launch_readiness], +) + + +def preflight(state: LaunchWorkflowState) -> dict[str, bool | list[HumanMessage]]: + """Adapt the structured request before delegating to the planner node.""" + return { + "preflight_complete": True, + "messages": [HumanMessage(content=state["request"])], + } + + +def complete(_: LaunchWorkflowState) -> dict[str, bool]: + """Record that the parent workflow has completed the planner stage.""" + return {"workflow_complete": True} + + +builder = StateGraph(LaunchWorkflowState, input_schema=LaunchRequest) +builder.add_node("preflight", preflight) +builder.add_node("launch_planner", launch_planner) +builder.add_node("complete", complete) +builder.add_edge(START, "preflight") +builder.add_edge("preflight", "launch_planner") +builder.add_edge("launch_planner", "complete") +builder.add_edge("complete", END) + +graph = with_uipath_managed_workspace(builder.compile()) diff --git a/samples/coded-deepagent-node/input.json b/samples/coded-deepagent-node/input.json new file mode 100644 index 000000000..c9ac47408 --- /dev/null +++ b/samples/coded-deepagent-node/input.json @@ -0,0 +1,3 @@ +{ + "request": "Create a six-week pilot launch plan for an Invoice Exception Copilot used by accounts payable managers at three enterprise customers. Security review must complete before the external rollout, and the support team needs enablement material." +} diff --git a/samples/coded-deepagent-node/langgraph.json b/samples/coded-deepagent-node/langgraph.json new file mode 100644 index 000000000..c465a881b --- /dev/null +++ b/samples/coded-deepagent-node/langgraph.json @@ -0,0 +1,7 @@ +{ + "dependencies": ["."], + "graphs": { + "agent": "./graph.py:graph" + }, + "env": ".env" +} diff --git a/samples/coded-deepagent-node/pyproject.toml b/samples/coded-deepagent-node/pyproject.toml new file mode 100644 index 000000000..4e7219f84 --- /dev/null +++ b/samples/coded-deepagent-node/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "coded-deepagent-node" +version = "0.0.1" +description = "Parent LangGraph workflow with an embedded UiPath DeepAgent node" +authors = [{ name = "UiPath", email = "support@uipath.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-langchain>=0.15.0, <0.16.0", +] + +[tool.uv.sources] +uipath-langchain = { path = "../..", editable = true } + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/samples/coded-deepagent-node/uipath.json b/samples/coded-deepagent-node/uipath.json new file mode 100644 index 000000000..56db223a3 --- /dev/null +++ b/samples/coded-deepagent-node/uipath.json @@ -0,0 +1,6 @@ +{ + "runtimeOptions": { + "isConversational": false + }, + "id": "be591088-69a1-4cb7-b472-32ad7acd3735" +} diff --git a/samples/coded-deepagent/README.md b/samples/coded-deepagent/README.md new file mode 100644 index 000000000..d5d3464f8 --- /dev/null +++ b/samples/coded-deepagent/README.md @@ -0,0 +1,53 @@ +# Coded DeepAgent + +This sample demonstrates a task-mode coded agent built with +`uipath_langchain.deepagents.create_uipath_deep_agent`. + +UiPath owns the filesystem backend and checkpointer configuration. The graph +declares a managed-workspace runtime requirement, which the UiPath runtime can +satisfy with a disk-backed workspace hydrated 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 `UiPathAzureChatOpenAI`. +- Access to the configured model, `gpt-5.4`. + +## Installation + +```bash +cd samples/coded-deepagent +uv sync +uv run uipath auth +``` + +Managed workspace hydration is opt-in. Enable it before running this sample: + +```bash +export UIPATH_FEATURE_DeepAgentsWorkspaceHydration=true +``` + +## 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..0ca47451d --- /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 UiPathAzureChatOpenAI +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 = UiPathAzureChatOpenAI(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 available planning tools. Use score_launch_readiness to get +a deterministic readiness signal. Delegate a plan review to risk_reviewer before +finalizing the answer. + +Use the provided filesystem tools 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..296feadc2 --- /dev/null +++ b/samples/coded-deepagent/pyproject.toml @@ -0,0 +1,14 @@ +[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.15.0, <0.16.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..a5d627961 --- /dev/null +++ b/src/uipath_langchain/deepagents/__init__.py @@ -0,0 +1,17 @@ +"""UiPath DeepAgents integration.""" + +from typing import Any + +__all__ = ["create_uipath_deep_agent", "with_uipath_managed_workspace"] + + +def __getattr__(name: str) -> Any: + if name == "create_uipath_deep_agent": + from .agent import create_uipath_deep_agent + + return create_uipath_deep_agent + if name == "with_uipath_managed_workspace": + from .metadata import with_uipath_managed_workspace + + return with_uipath_managed_workspace + 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..34f8884e2 --- /dev/null +++ b/src/uipath_langchain/deepagents/agent.py @@ -0,0 +1,63 @@ +"""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 +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 runtime-managed DeepAgents arguments. UiPath +# provides the filesystem backend and checkpointer when it compiles the graph. +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, + 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(), + 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..c71f35e16 --- /dev/null +++ b/src/uipath_langchain/deepagents/metadata.py @@ -0,0 +1,76 @@ +"""Runtime requirements for UiPath-created DeepAgents graphs.""" + +from collections.abc import Mapping +from typing import Any, TypeVar + +from langgraph.graph.state import CompiledStateGraph + +_UIPATH_RUNTIME_METADATA_KEY = "uipath_runtime" +_REQUIREMENTS_METADATA_KEY = "requirements" +_MANAGED_WORKSPACE_REQUIREMENT = "managed_workspace" + +CompiledGraphT = TypeVar( + "CompiledGraphT", + bound=CompiledStateGraph[Any, Any, Any, Any], +) + + +def mark_uipath_deep_agent( + graph: CompiledGraphT, +) -> CompiledGraphT: + """Declare the runtime requirement of a top-level UiPath DeepAgent.""" + return with_uipath_managed_workspace(graph) + + +def with_uipath_managed_workspace( + graph: CompiledGraphT, +) -> CompiledGraphT: + """Bind the managed-workspace requirement to a compiled entrypoint graph. + + Call this on a parent graph when a UiPath DeepAgent is embedded in a larger + LangGraph or runnable composition. Requirements are declared at the + entrypoint instead of inferred from third-party runnable internals. + """ + return graph.with_config( + { + "metadata": { + _UIPATH_RUNTIME_METADATA_KEY: { + _REQUIREMENTS_METADATA_KEY: [_MANAGED_WORKSPACE_REQUIREMENT], + }, + } + } + ) + + +def get_runtime_requirements( + graph: CompiledStateGraph[Any, Any, Any, Any], +) -> frozenset[str]: + """Return the requirements explicitly bound to an entrypoint graph.""" + config = graph.config or {} + metadata = config.get("metadata", {}) if isinstance(config, Mapping) else {} + runtime_metadata = ( + metadata.get(_UIPATH_RUNTIME_METADATA_KEY, {}) + if isinstance(metadata, Mapping) + else {} + ) + declared_requirements = ( + runtime_metadata.get(_REQUIREMENTS_METADATA_KEY, ()) + if isinstance(runtime_metadata, Mapping) + else () + ) + if isinstance(declared_requirements, str): + return frozenset({declared_requirements}) + if isinstance(declared_requirements, (list, tuple, set, frozenset)): + return frozenset( + requirement + for requirement in declared_requirements + if isinstance(requirement, str) + ) + return frozenset() + + +def requires_managed_workspace( + graph: CompiledStateGraph[Any, Any, Any, Any], +) -> bool: + """Return whether an entrypoint graph needs a managed workspace.""" + return _MANAGED_WORKSPACE_REQUIREMENT in get_runtime_requirements(graph) 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..231be648e 100644 --- a/src/uipath_langchain/runtime/factory.py +++ b/src/uipath_langchain/runtime/factory.py @@ -1,6 +1,10 @@ import asyncio +import hashlib import os -from typing import Any, AsyncContextManager +import shutil +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Any, AsyncContextManager, Protocol from langchain_core.callbacks import BaseCallbackHandler from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver @@ -11,20 +15,28 @@ get_current_span, ) from uipath.core.adapters import EvaluatorProtocol +from uipath.core.feature_flags import FeatureFlags 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 requires_managed_workspace from uipath_langchain.governance import GovernanceCallbackHandler from uipath_langchain.runtime.config import LangGraphConfig from uipath_langchain.runtime.errors import LangGraphErrorCode, LangGraphRuntimeError @@ -34,6 +46,33 @@ _AGENT_TYPE_CODED = "uipath_coded" _AGENT_FRAMEWORK = "langchain" +_MANAGED_WORKSPACE_HYDRATION_FEATURE_FLAG = "DeepAgentsWorkspaceHydration" + + +class _AsyncClosable(Protocol): + """Public cleanup contract implemented by hydration platform services.""" + + async def aclose(self) -> None: ... + + +class _UiPathHydrationRuntime(HydrationRuntime): + """Hydration runtime that owns the platform services it lazily creates.""" + + def __init__( + self, + *, + dispose_platform_services: Callable[[], Awaitable[None]], + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self._dispose_platform_services = dispose_platform_services + + async def dispose(self) -> None: + """Dispose the delegate, workspace, and owned platform services.""" + try: + await super().dispose() + finally: + await self._dispose_platform_services() class UiPathLangGraphRuntimeFactory: @@ -195,8 +234,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 +260,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 +339,93 @@ async def _create_runtime_instance( else None ) + workspace = ( + await self._create_managed_workspace(runtime_id) + if self._uses_managed_workspace(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 + + platform_services: tuple[_AsyncClosable, ...] | None = None + + def create_hydrator() -> WorkspaceHydrator: + nonlocal platform_services + sdk = UiPath() + attachments = sdk.attachments + jobs = sdk.jobs + platform_services = (attachments, jobs) + return WorkspaceHydrator( + workspace_path=workspace.path, + attachments=attachments, + jobs=jobs, + current_job_key=self.context.job_id, + folder_key=self.context.folder_key, + ) + + async def dispose_platform_services() -> None: + if platform_services is not None: + await asyncio.gather( + *(service.aclose() for service in platform_services) + ) + + return _UiPathHydrationRuntime( + delegate=resumable_runtime, + workspace=workspace, + hydrator_factory=create_hydrator, + registry_store=WorkspaceRegistryStore( + storage, + runtime_id, + ), + policy=self._select_deep_agent_hydration_policy(), + dispose_platform_services=dispose_platform_services, + ) + + @staticmethod + def _uses_managed_workspace( + compiled_graph: CompiledStateGraph[Any, Any, Any, Any], + ) -> bool: + """Return whether enabled runtime capabilities require a workspace.""" + return FeatureFlags.is_flag_enabled( + _MANAGED_WORKSPACE_HYDRATION_FEATURE_FLAG, + default=False, + ) and requires_managed_workspace(compiled_graph) + + def _select_deep_agent_hydration_policy(self) -> HydrationPolicy: + """Select the UiPath-owned hydration lifecycle for this execution mode.""" + return HydrationPolicy.SUSPEND_OR_SUCCESS + + async def _create_managed_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(): + await asyncio.to_thread(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..42a24386d --- /dev/null +++ b/tests/deepagents/test_agent.py @@ -0,0 +1,67 @@ +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 requires_managed_workspace + + +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=[], + 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["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 "interrupt_on" not in kwargs + assert isinstance(kwargs["backend"], _UiPathWorkspaceBackendFactory) + assert requires_managed_workspace(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..de9bb1f45 --- /dev/null +++ b/tests/deepagents/test_metadata.py @@ -0,0 +1,76 @@ +import inspect +from unittest.mock import MagicMock + +from deepagents import create_deep_agent +from langchain_core.language_models import BaseChatModel +from langgraph.graph import END, START, StateGraph +from typing_extensions import TypedDict + +from uipath_langchain.deepagents import ( + create_uipath_deep_agent, + with_uipath_managed_workspace, +) +from uipath_langchain.deepagents.metadata import requires_managed_workspace + + +class _State(TypedDict): + value: str + + +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", + "interrupt_on", + } + + 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 requires_managed_workspace(graph) + assert graph.config["metadata"]["uipath_runtime"] == { + "requirements": ["managed_workspace"] + } + assert graph.config["metadata"]["ls_integration"] == "deepagents" + + +def test_standard_deepagent_has_no_managed_workspace_requirement() -> None: + graph = create_deep_agent(model=_model()) + + assert not requires_managed_workspace(graph) + + +def test_requirement_detection_tolerates_malformed_metadata() -> None: + graph = MagicMock() + graph.config = {"metadata": None} + + assert not requires_managed_workspace(graph) + + +def test_parent_requirement_is_explicitly_registered() -> None: + child = create_uipath_deep_agent(model=_model()) + parent = StateGraph(_State) + parent.add_node("deep_agent", child) + parent.add_edge(START, "deep_agent") + parent.add_edge("deep_agent", END) + + assert not requires_managed_workspace(parent.compile()) + assert requires_managed_workspace(with_uipath_managed_workspace(parent.compile())) 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..52bb9ffbc --- /dev/null +++ b/tests/deepagents/test_runtime_factory.py @@ -0,0 +1,396 @@ +import asyncio +import hashlib +import shutil +import time +from pathlib import Path +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import UUID, uuid4 + +import pytest +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.core.feature_flags import FeatureFlags +from uipath.runtime import ( + HydrationPolicy, + HydrationRuntime, + UiPathResumableRuntime, + UiPathRuntimeContext, + UiPathRuntimeResult, + WorkspaceHydrator, + WorkspaceRegistryStore, +) + +from uipath_langchain.deepagents.metadata import ( + mark_uipath_deep_agent, + requires_managed_workspace, + with_uipath_managed_workspace, +) +from uipath_langchain.runtime.factory import ( + _MANAGED_WORKSPACE_HYDRATION_FEATURE_FLAG, + 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()) + + +@pytest.fixture(autouse=True) +def _configure_managed_workspace_feature_flag(): + FeatureFlags.reset_flags() + FeatureFlags.configure_flags({_MANAGED_WORKSPACE_HYDRATION_FEATURE_FLAG: True}) + yield + FeatureFlags.reset_flags() + + +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 requires_managed_workspace(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_runtime": {"requirements": ["managed_workspace"]}, + } + + +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 requires_managed_workspace(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") + sdk.attachments.aclose = AsyncMock() + sdk.jobs.aclose = AsyncMock() + + 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" + await runtime.dispose() + + +async def test_nested_deep_agent_runtime_uses_hydrated_workspace(tmp_path) -> None: + context = UiPathRuntimeContext(runtime_dir=str(tmp_path), state_file="state.db") + factory = UiPathLangGraphRuntimeFactory(context) + child = mark_uipath_deep_agent(_build_graph().compile()) + parent = StateGraph(_State) + parent.add_node("deep_agent", child) + parent.add_edge(START, "deep_agent") + parent.add_edge("deep_agent", END) + + with patch.object(factory, "_get_memory", AsyncMock(return_value=MagicMock())): + runtime = await factory._create_runtime_instance( + compiled_graph=with_uipath_managed_workspace(parent.compile()), + runtime_id="runtime-1", + entrypoint="agent", + ) + + assert isinstance(runtime, HydrationRuntime) + await runtime.dispose() + + +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() + + +async def test_marked_graph_does_not_hydrate_when_feature_is_disabled(tmp_path) -> None: + FeatureFlags.configure_flags({_MANAGED_WORKSPACE_HYDRATION_FEATURE_FLAG: False}) + context = UiPathRuntimeContext(runtime_dir=str(tmp_path), state_file="state.db") + factory = UiPathLangGraphRuntimeFactory(context) + + with patch.object(factory, "_get_memory", AsyncMock(return_value=MagicMock())): + runtime = await factory._create_runtime_instance( + compiled_graph=mark_uipath_deep_agent(_build_graph().compile()), + runtime_id="runtime-1", + entrypoint="agent", + ) + + assert isinstance(runtime, UiPathResumableRuntime) + assert not isinstance(runtime, HydrationRuntime) + + +async def test_hydration_runtime_closes_platform_services(tmp_path) -> None: + context = UiPathRuntimeContext(runtime_dir=str(tmp_path), state_file="state.db") + factory = UiPathLangGraphRuntimeFactory(context) + attachments = MagicMock() + attachments.aclose = AsyncMock() + jobs = MagicMock() + jobs.aclose = AsyncMock() + sdk = MagicMock(attachments=attachments, jobs=jobs) + + with ( + patch("uipath_langchain.runtime.factory.UiPath", return_value=sdk), + patch.object(factory, "_get_memory", AsyncMock(return_value=MagicMock())), + ): + runtime = await factory._create_runtime_instance( + compiled_graph=mark_uipath_deep_agent(_build_graph().compile()), + runtime_id="runtime-1", + entrypoint="agent", + ) + cast(HydrationRuntime, runtime)._get_hydrator() + await runtime.dispose() + + attachments.aclose.assert_awaited_once() + jobs.aclose.assert_awaited_once() + + +async def test_managed_workspace_is_deterministic_and_path_safe(tmp_path) -> None: + context = UiPathRuntimeContext(runtime_dir=str(tmp_path), state_file="state.db") + factory = UiPathLangGraphRuntimeFactory(context) + + workspace = await factory._create_managed_workspace("../../outside") + + assert workspace.path.parent == tmp_path / "workspaces" + assert workspace.path.name == hashlib.sha256(b"../../outside").hexdigest() + + +async def test_managed_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 = await factory._create_managed_workspace("runtime-1") + stale_file = workspace.path / "stale.txt" + stale_file.write_text("stale") + + recreated = await factory._create_managed_workspace("runtime-1") + + assert recreated.path == workspace.path + assert not stale_file.exists() + + +async def test_managed_workspace_cleanup_does_not_block_event_loop( + tmp_path, monkeypatch +) -> None: + context = UiPathRuntimeContext(runtime_dir=str(tmp_path), state_file="state.db") + factory = UiPathLangGraphRuntimeFactory(context) + workspace = await factory._create_managed_workspace("runtime-1") + (workspace.path / "stale.txt").write_text("stale") + original_rmtree = shutil.rmtree + + def slow_rmtree(path: Path) -> None: + time.sleep(0.2) + original_rmtree(path) + + monkeypatch.setattr("uipath_langchain.runtime.factory.shutil.rmtree", slow_rmtree) + + async def heartbeat() -> float: + await asyncio.sleep(0.01) + return asyncio.get_running_loop().time() + + started_at = asyncio.get_running_loop().time() + recreate = asyncio.create_task(factory._create_managed_workspace("runtime-1")) + heartbeat_at = await heartbeat() + await recreate + + assert heartbeat_at - started_at < 0.1 diff --git a/uv.lock b/uv.lock index b1a4878fc..616457cfb 100644 --- a/uv.lock +++ b/uv.lock @@ -4498,7 +4498,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.14.15" +version = "0.15.0" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -4588,7 +4588,7 @@ requires-dist = [ { name = "uipath-langchain-client", extras = ["openai"], specifier = ">=1.17.1,<1.18.0" }, { name = "uipath-langchain-client", extras = ["vertexai"], marker = "extra == 'vertex'", specifier = ">=1.17.1,<1.18.0" }, { name = "uipath-llm-client", specifier = ">=1.17.1,<1.18.0" }, - { name = "uipath-platform", specifier = ">=0.2.12,<0.3.0" }, + { name = "uipath-platform", specifier = ">=0.2.13,<0.3.0" }, { name = "uipath-runtime", specifier = ">=0.12.5,<0.13.0" }, ] provides-extras = ["anthropic", "vertex", "bedrock", "fireworks", "all"] @@ -4672,9 +4672,10 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.12" +version = "0.2.13" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "anyio" }, { name = "httpx" }, { name = "pydantic-function-models" }, { name = "sqlparse" }, @@ -4682,9 +4683,9 @@ dependencies = [ { name = "truststore" }, { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/57/d5db35a96fe6acedb74f8c8ce28175e296e9968df9279b244027f151b09e/uipath_platform-0.2.12.tar.gz", hash = "sha256:d60c699f7ca2357106dafd001aa8644d4ba1a7a27d328ab456993627b809fdcc", size = 427943, upload-time = "2026-07-21T09:25:47.829Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/b8/120398352065de19b64796ad2695ef13a187732af57161858e49f6b06d24/uipath_platform-0.2.13.tar.gz", hash = "sha256:93c22c28e4114fb79e8e3392c4086ce940d70a6760713d41ec26bf4e35817659", size = 428697, upload-time = "2026-07-22T16:14:57.564Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/b5/2e8d8ae1683c502bbf1dde307857078d90e1907449ede83e9fb2dbfb6138/uipath_platform-0.2.12-py3-none-any.whl", hash = "sha256:2958d5d15c70b0aefd54dd865eb20eb5ceec61aac664ebe56d0ab314127f8e4b", size = 281072, upload-time = "2026-07-21T09:25:46.261Z" }, + { url = "https://files.pythonhosted.org/packages/65/cc/e96e869395571bd86385765ba691dbcc8f6ef466ae3a802ddf95ff5bd631/uipath_platform-0.2.13-py3-none-any.whl", hash = "sha256:ff2658766c2bed7740ff1d341104c03c7c8b29a51bfecba62cd0ec8c0497b173", size = 281491, upload-time = "2026-07-22T16:14:56.186Z" }, ] [[package]]