From a9136c7bde4bf2691d220223a9a209a619831e01 Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Wed, 15 Jul 2026 16:55:02 +0300 Subject: [PATCH 1/5] feat: support coded DeepAgents --- samples/README.md | 3 + samples/coded-deepagent/README.md | 46 ++++++ 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 | 15 ++ samples/coded-deepagent/uipath.json | 5 + src/uipath_langchain/deepagents/__init__.py | 13 ++ src/uipath_langchain/deepagents/agent.py | 79 ++++++++++ src/uipath_langchain/deepagents/backend.py | 31 ++++ src/uipath_langchain/deepagents/metadata.py | 31 ++++ src/uipath_langchain/runtime/_workspace.py | 85 +++++++++++ src/uipath_langchain/runtime/factory.py | 68 ++++++++- src/uipath_langchain/runtime/runtime.py | 11 ++ tests/deepagents/__init__.py | 0 tests/deepagents/test_agent.py | 71 +++++++++ tests/deepagents/test_backend.py | 68 +++++++++ tests/deepagents/test_metadata.py | 45 ++++++ tests/deepagents/test_runtime_config.py | 27 ++++ tests/deepagents/test_runtime_factory.py | 160 ++++++++++++++++++++ 21 files changed, 848 insertions(+), 6 deletions(-) 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/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..8c5958287 --- /dev/null +++ b/samples/coded-deepagent/README.md @@ -0,0 +1,46 @@ +# Coded DeepAgent + +This sample demonstrates a task-mode coded agent built with +`uipath_langchain.deepagents.create_uipath_deep_agent`. + +The API matches `deepagents.create_deep_agent`, except that UiPath owns the +filesystem backend. 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 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..c2d1a0119 --- /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", + "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..66cc3cc72 --- /dev/null +++ b/src/uipath_langchain/deepagents/agent.py @@ -0,0 +1,79 @@ +"""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.cache.base import BaseCache +from langgraph.graph.state import CompiledStateGraph +from langgraph.store.base import BaseStore +from langgraph.types import Checkpointer +from langgraph.typing import ContextT + +from .backend import _UiPathWorkspaceBackendFactory +from .metadata import mark_uipath_deep_agent + + +# This API intentionally omits DeepAgents' ``backend`` argument. UiPath-compatible +# DeepAgents must use the UiPath-managed FilesystemBackend so the runtime can +# hydrate and persist their workspace through attachments. +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, + checkpointer: Checkpointer | None = None, + store: BaseStore | None = None, + debug: bool = False, + name: str | None = None, + cache: BaseCache[Any] | 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 the required filesystem backend. + """ + 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, + checkpointer=checkpointer, + store=store, + debug=debug, + name=name, + cache=cache, + ) + 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..e8ee4309f --- /dev/null +++ b/src/uipath_langchain/deepagents/backend.py @@ -0,0 +1,31 @@ +"""Workspace backend helpers for UiPath DeepAgents.""" + +from pathlib import Path + +from deepagents.backends import FilesystemBackend +from langchain.tools import ToolRuntime +from langgraph.config import get_config + +from uipath_langchain.runtime._workspace import WORKSPACE_PATH_CONFIG_KEY + + +class _UiPathWorkspaceBackendFactory: + """DeepAgents backend factory resolved from UiPath runtime config.""" + + def __call__(self, runtime: ToolRuntime) -> FilesystemBackend: + config = getattr(runtime, "config", None) + if config is None: + try: + config = get_config() + except RuntimeError: + config = {} + configurable = ( + config.get("configurable", {}) if isinstance(config, dict) else {} + ) + workspace_path = configurable.get(WORKSPACE_PATH_CONFIG_KEY) + if not workspace_path: + raise RuntimeError( + "UiPath DeepAgents workspace path is unavailable. Run graphs created " + "by create_uipath_deep_agent through the UiPath runtime." + ) + return FilesystemBackend(root_dir=Path(workspace_path), 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..160832fc3 --- /dev/null +++ b/src/uipath_langchain/deepagents/metadata.py @@ -0,0 +1,31 @@ +"""Runtime marker for UiPath-created DeepAgents graphs.""" + +from typing import Any, TypeVar + +_UIPATH_INTEGRATION_METADATA_KEY = "uipath_integration" +_UIPATH_DEEPAGENTS_INTEGRATION = "deepagents" + +GraphT = TypeVar("GraphT") + + +def mark_uipath_deep_agent(graph: GraphT) -> GraphT: + """Return a graph copy with explicit UiPath DeepAgents runtime metadata.""" + with_config = graph.with_config # type: ignore[attr-defined] + return with_config( + { + "metadata": { + _UIPATH_INTEGRATION_METADATA_KEY: _UIPATH_DEEPAGENTS_INTEGRATION, + } + } + ) + + +def is_uipath_deep_agent(graph: 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..884f7f932 --- /dev/null +++ b/src/uipath_langchain/runtime/_workspace.py @@ -0,0 +1,85 @@ +"""Internal workspace integration shared by runtime-backed agents.""" + +from typing import Any +from uuid import UUID + +from uipath.platform import UiPath + +WORKSPACE_PATH_CONFIG_KEY = "uipath_workspace_path" + + +class LazyUiPathWorkspaceServices: + """Resolve UiPath attachment and job services on first remote operation.""" + + def __init__(self) -> None: + self._sdk: UiPath | None = None + + def _get_sdk(self) -> UiPath: + if self._sdk is None: + self._sdk = UiPath() + return self._sdk + + async def download_async( + self, + *, + key: UUID, + destination_path: str, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> str: + return await self._get_sdk().attachments.download_async( + key=key, + destination_path=destination_path, + folder_key=folder_key, + folder_path=folder_path, + ) + + 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: + upload: dict[str, Any] = { + "name": name, + "folder_key": folder_key, + "folder_path": folder_path, + } + if source_path is not None: + upload["source_path"] = source_path + elif content is not None: + upload["content"] = content + else: + raise ValueError("Workspace attachment upload requires content or a path") + return await self._get_sdk().attachments.upload_async(**upload) + + async def list_attachments_async( + self, + *, + job_key: UUID, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> list[str]: + return await self._get_sdk().jobs.list_attachments_async( + job_key=job_key, + folder_key=folder_key, + folder_path=folder_path, + ) + + async def link_attachment_async( + self, + *, + job_key: UUID, + attachment_key: UUID, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> None: + await self._get_sdk().jobs.link_attachment_async( + job_key=job_key, + attachment_key=attachment_key, + folder_key=folder_key, + folder_path=folder_path, + ) diff --git a/src/uipath_langchain/runtime/factory.py b/src/uipath_langchain/runtime/factory.py index 9d16137b5..d019f7b53 100644 --- a/src/uipath_langchain/runtime/factory.py +++ b/src/uipath_langchain/runtime/factory.py @@ -1,10 +1,14 @@ import asyncio +import hashlib import os +import shutil +from pathlib import Path from typing import Any, AsyncContextManager from langchain_core.callbacks import BaseCallbackHandler from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver from langgraph.graph.state import CompiledStateGraph, StateGraph +from langgraph.types import Checkpointer from openinference.instrumentation.langchain import ( LangChainInstrumentor, get_ancestor_spans, @@ -16,16 +20,25 @@ 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._workspace import LazyUiPathWorkspaceServices from uipath_langchain.runtime.config import LangGraphConfig from uipath_langchain.runtime.errors import LangGraphErrorCode, LangGraphRuntimeError from uipath_langchain.runtime.graph import LangGraphLoader @@ -34,6 +47,7 @@ _AGENT_TYPE_CODED = "uipath_coded" _AGENT_FRAMEWORK = "langchain" +_DEEPAGENT_HYDRATION_POLICY = HydrationPolicy.SUSPEND_OR_SUCCESS class UiPathLangGraphRuntimeFactory: @@ -182,7 +196,7 @@ async def _load_graph( async def _compile_graph( self, graph: StateGraph[Any, Any, Any] | CompiledStateGraph[Any, Any, Any, Any], - memory: AsyncSqliteSaver, + memory: Checkpointer, ) -> CompiledStateGraph[Any, Any, Any, Any]: """ Compile a graph with the given memory/checkpointer. @@ -195,11 +209,12 @@ 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 + self, entrypoint: str, memory: Checkpointer, **kwargs ) -> CompiledStateGraph[Any, Any, Any, Any]: """ Resolve a graph from configuration and compile it. @@ -220,7 +235,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 +314,63 @@ 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 + + services = LazyUiPathWorkspaceServices() + return HydrationRuntime( + delegate=resumable_runtime, + workspace=workspace, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=services, + jobs=services, + current_job_key=self.context.job_id, + folder_key=self.context.folder_key, + ), + registry_store=WorkspaceRegistryStore( + storage, + runtime_id, + ), + policy=_DEEPAGENT_HYDRATION_POLICY, + ) + + 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..3932b5527 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 WORKSPACE_PATH_CONFIG_KEY # 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() @@ -352,6 +359,10 @@ def _get_graph_config(self) -> RunnableConfig: "configurable": {"thread_id": self.runtime_id}, "callbacks": self.callbacks, } + if self.workspace_path is not None: + graph_config["configurable"][WORKSPACE_PATH_CONFIG_KEY] = str( + self.workspace_path + ) # Add optional config from environment recursion_limit = os.environ.get("LANGCHAIN_RECURSION_LIMIT") 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..62247e8aa --- /dev/null +++ b/tests/deepagents/test_agent.py @@ -0,0 +1,71 @@ +from unittest.mock import MagicMock, patch + +from langgraph.graph import END, START, StateGraph + +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 + + +def _compiled_graph(): + builder = StateGraph(dict) + 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() + checkpointer = MagicMock() + store = MagicMock() + cache = 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, + checkpointer=checkpointer, + store=store, + debug=True, + name="agent", + cache=cache, + ) + + 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 kwargs["checkpointer"] is checkpointer + assert kwargs["store"] is store + assert kwargs["debug"] is True + assert kwargs["name"] == "agent" + assert kwargs["cache"] is cache + 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..7d5aab1bb --- /dev/null +++ b/tests/deepagents/test_backend.py @@ -0,0 +1,68 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import patch + +import pytest +from deepagents.backends import FilesystemBackend +from langchain.tools import ToolRuntime + +from uipath_langchain.deepagents.backend import _UiPathWorkspaceBackendFactory +from uipath_langchain.runtime._workspace import WORKSPACE_PATH_CONFIG_KEY + + +def _tool_runtime(config: dict[str, Any]) -> ToolRuntime: + return ToolRuntime( + state=None, + 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": {}})) + + +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..eea91f38c --- /dev/null +++ b/tests/deepagents/test_metadata.py @@ -0,0 +1,45 @@ +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_parameters_except_backend() -> None: + upstream = inspect.signature(create_deep_agent).parameters + uipath = inspect.signature(create_uipath_deep_agent).parameters + + assert list(uipath) == [name for name in upstream if name != "backend"] + 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..751d5886c --- /dev/null +++ b/tests/deepagents/test_runtime_factory.py @@ -0,0 +1,160 @@ +import hashlib +from typing import Any, TypedDict +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.graph import END, START, StateGraph +from uipath.runtime import HydrationPolicy, HydrationRuntime, UiPathRuntimeContext + +from uipath_langchain.deepagents.metadata import ( + is_uipath_deep_agent, + mark_uipath_deep_agent, +) +from uipath_langchain.runtime._workspace import LazyUiPathWorkspaceServices +from uipath_langchain.runtime.factory import UiPathLangGraphRuntimeFactory + + +class _State(TypedDict): + value: str + + +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 + + +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, InMemorySaver()) + + assert is_uipath_deep_agent(result) + assert result.config["recursion_limit"] == 9999 + assert result.config["tags"] == ["bound-tag"] + assert result.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", InMemorySaver()) + second = await factory._resolve_and_compile_graph("agent", InMemorySaver()) + + 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._workspace.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", + ) + sdk_constructor.assert_not_called() + services = runtime.hydrator.attachments + assert isinstance(services, LazyUiPathWorkspaceServices) + await services.download_async( + key=uuid4(), destination_path=str(tmp_path / "downloaded") + ) + sdk_constructor.assert_called_once_with() + + assert isinstance(runtime, HydrationRuntime) + 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 + langgraph_runtime = resumable_runtime.delegate + assert langgraph_runtime.workspace_path == runtime.workspace.path + + assert runtime.hydrator.workspace_path == runtime.workspace.path + assert runtime.hydrator.jobs is runtime.hydrator.attachments + assert runtime.hydrator.current_job_key == "job-1" + assert runtime.hydrator.folder_key == "folder-1" + assert runtime.hydrator.attachment_prefix == ".uipath-workspace" + assert runtime.registry_store.runtime_id == "runtime-1" + + +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._workspace.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 runtime.delegate.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() From 66a573c8eff7667ed124d5d1f139a9891f9c3977 Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Wed, 15 Jul 2026 18:16:39 +0300 Subject: [PATCH 2/5] refactor: tighten DeepAgents runtime contracts --- src/uipath_langchain/deepagents/backend.py | 19 ++++------ src/uipath_langchain/deepagents/metadata.py | 18 +++++++--- src/uipath_langchain/runtime/_workspace.py | 39 ++++++++++++++++++++- src/uipath_langchain/runtime/factory.py | 7 ++-- src/uipath_langchain/runtime/runtime.py | 11 +++--- tests/deepagents/test_backend.py | 12 +++++++ 6 files changed, 79 insertions(+), 27 deletions(-) diff --git a/src/uipath_langchain/deepagents/backend.py b/src/uipath_langchain/deepagents/backend.py index e8ee4309f..3ff6f3565 100644 --- a/src/uipath_langchain/deepagents/backend.py +++ b/src/uipath_langchain/deepagents/backend.py @@ -1,31 +1,24 @@ """Workspace backend helpers for UiPath DeepAgents.""" -from pathlib import Path - 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 WORKSPACE_PATH_CONFIG_KEY +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 = getattr(runtime, "config", None) + config: RunnableConfig | None = getattr(runtime, "config", None) if config is None: try: config = get_config() except RuntimeError: config = {} - configurable = ( - config.get("configurable", {}) if isinstance(config, dict) else {} + return FilesystemBackend( + root_dir=get_workspace_path(config), + virtual_mode=True, ) - workspace_path = configurable.get(WORKSPACE_PATH_CONFIG_KEY) - if not workspace_path: - raise RuntimeError( - "UiPath DeepAgents workspace path is unavailable. Run graphs created " - "by create_uipath_deep_agent through the UiPath runtime." - ) - return FilesystemBackend(root_dir=Path(workspace_path), virtual_mode=True) diff --git a/src/uipath_langchain/deepagents/metadata.py b/src/uipath_langchain/deepagents/metadata.py index 160832fc3..421836a82 100644 --- a/src/uipath_langchain/deepagents/metadata.py +++ b/src/uipath_langchain/deepagents/metadata.py @@ -2,16 +2,22 @@ from typing import Any, TypeVar +from langgraph.graph.state import CompiledStateGraph + _UIPATH_INTEGRATION_METADATA_KEY = "uipath_integration" _UIPATH_DEEPAGENTS_INTEGRATION = "deepagents" -GraphT = TypeVar("GraphT") +CompiledGraphT = TypeVar( + "CompiledGraphT", + bound=CompiledStateGraph[Any, Any, Any, Any], +) -def mark_uipath_deep_agent(graph: GraphT) -> GraphT: +def mark_uipath_deep_agent( + graph: CompiledGraphT, +) -> CompiledGraphT: """Return a graph copy with explicit UiPath DeepAgents runtime metadata.""" - with_config = graph.with_config # type: ignore[attr-defined] - return with_config( + return graph.with_config( { "metadata": { _UIPATH_INTEGRATION_METADATA_KEY: _UIPATH_DEEPAGENTS_INTEGRATION, @@ -20,7 +26,9 @@ def mark_uipath_deep_agent(graph: GraphT) -> GraphT: ) -def is_uipath_deep_agent(graph: Any) -> bool: +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 {} diff --git a/src/uipath_langchain/runtime/_workspace.py b/src/uipath_langchain/runtime/_workspace.py index 884f7f932..4d74d404c 100644 --- a/src/uipath_langchain/runtime/_workspace.py +++ b/src/uipath_langchain/runtime/_workspace.py @@ -1,13 +1,50 @@ """Internal workspace integration shared by runtime-backed agents.""" -from typing import Any +from pathlib import Path +from typing import Any, NotRequired, TypedDict from uuid import UUID +from langchain_core.runnables import RunnableConfig from uipath.platform import UiPath 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) + + class LazyUiPathWorkspaceServices: """Resolve UiPath attachment and job services on first remote operation.""" diff --git a/src/uipath_langchain/runtime/factory.py b/src/uipath_langchain/runtime/factory.py index d019f7b53..612bd52d2 100644 --- a/src/uipath_langchain/runtime/factory.py +++ b/src/uipath_langchain/runtime/factory.py @@ -47,7 +47,6 @@ _AGENT_TYPE_CODED = "uipath_coded" _AGENT_FRAMEWORK = "langchain" -_DEEPAGENT_HYDRATION_POLICY = HydrationPolicy.SUSPEND_OR_SUCCESS class UiPathLangGraphRuntimeFactory: @@ -354,9 +353,13 @@ async def _create_runtime_instance( storage, runtime_id, ), - policy=_DEEPAGENT_HYDRATION_POLICY, + 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, diff --git a/src/uipath_langchain/runtime/runtime.py b/src/uipath_langchain/runtime/runtime.py index 3932b5527..c305670ff 100644 --- a/src/uipath_langchain/runtime/runtime.py +++ b/src/uipath_langchain/runtime/runtime.py @@ -43,7 +43,7 @@ from uipath_langchain.runtime.schema import get_entrypoints_schema, get_graph_schema from ._serialize import serialize_output -from ._workspace import WORKSPACE_PATH_CONFIG_KEY +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 @@ -356,13 +356,12 @@ 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, } - if self.workspace_path is not None: - graph_config["configurable"][WORKSPACE_PATH_CONFIG_KEY] = str( - self.workspace_path - ) # Add optional config from environment recursion_limit = os.environ.get("LANGCHAIN_RECURSION_LIMIT") diff --git a/tests/deepagents/test_backend.py b/tests/deepagents/test_backend.py index 7d5aab1bb..04c2bb3ef 100644 --- a/tests/deepagents/test_backend.py +++ b/tests/deepagents/test_backend.py @@ -54,6 +54,18 @@ def test_workspace_backend_factory_raises_when_config_missing() -> None: 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: From a278c74ba14132b83c4054956ec13bff374f022e Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Thu, 16 Jul 2026 14:30:40 +0300 Subject: [PATCH 3/5] refactor: narrow DeepAgents runtime configuration --- samples/coded-deepagent/README.md | 7 +- samples/coded-deepagent/pyproject.toml | 2 +- src/uipath_langchain/deepagents/agent.py | 22 +---- src/uipath_langchain/runtime/factory.py | 5 +- tests/deepagents/test_agent.py | 18 +--- tests/deepagents/test_metadata.py | 5 +- tests/deepagents/test_runtime_factory.py | 111 ++++++++++++++++++++++- 7 files changed, 129 insertions(+), 41 deletions(-) diff --git a/samples/coded-deepagent/README.md b/samples/coded-deepagent/README.md index 8c5958287..e68f0cb92 100644 --- a/samples/coded-deepagent/README.md +++ b/samples/coded-deepagent/README.md @@ -3,9 +3,10 @@ This sample demonstrates a task-mode coded agent built with `uipath_langchain.deepagents.create_uipath_deep_agent`. -The API matches `deepagents.create_deep_agent`, except that UiPath owns the -filesystem backend. It marks the graph for the UiPath runtime, which creates -and hydrates a disk-backed workspace through job attachments. +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 diff --git a/samples/coded-deepagent/pyproject.toml b/samples/coded-deepagent/pyproject.toml index c2d1a0119..d6bd9ffa4 100644 --- a/samples/coded-deepagent/pyproject.toml +++ b/samples/coded-deepagent/pyproject.toml @@ -5,7 +5,7 @@ description = "Task-mode coded DeepAgent using the UiPath runtime workspace cont authors = [{ name = "UiPath", email = "support@uipath.com" }] requires-python = ">=3.11" dependencies = [ - "uipath-langchain", + "uipath-langchain>=0.14.10, <0.15.0", "uipath>=2.13.7, <2.14.0", ] diff --git a/src/uipath_langchain/deepagents/agent.py b/src/uipath_langchain/deepagents/agent.py index 66cc3cc72..f27f2e1f7 100644 --- a/src/uipath_langchain/deepagents/agent.py +++ b/src/uipath_langchain/deepagents/agent.py @@ -17,19 +17,17 @@ from langchain_core.language_models import BaseChatModel from langchain_core.messages import SystemMessage from langchain_core.tools import BaseTool -from langgraph.cache.base import BaseCache from langgraph.graph.state import CompiledStateGraph -from langgraph.store.base import BaseStore -from langgraph.types import Checkpointer from langgraph.typing import ContextT from .backend import _UiPathWorkspaceBackendFactory from .metadata import mark_uipath_deep_agent -# This API intentionally omits DeepAgents' ``backend`` argument. UiPath-compatible -# DeepAgents must use the UiPath-managed FilesystemBackend so the runtime can -# hydrate and persist their workspace through attachments. +# This API intentionally omits DeepAgents' compile-time arguments. +# UiPath-compatible DeepAgents use the runtime-managed filesystem backend and +# checkpointer. Other compile-time settings are unsupported because the runtime +# recompiles entrypoints with its managed configuration. def create_uipath_deep_agent( model: str | BaseChatModel | None = None, tools: Sequence[BaseTool | Callable[..., Any] | dict[str, Any]] | None = None, @@ -46,16 +44,11 @@ def create_uipath_deep_agent( | dict[str, Any] | None = None, context_schema: type[ContextT] | None = None, - checkpointer: Checkpointer | None = None, - store: BaseStore | None = None, - debug: bool = False, - name: str | None = None, - cache: BaseCache[Any] | 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 the required filesystem backend. + except that UiPath owns compile-time configuration. """ graph = _create_deep_agent( model=model, @@ -70,10 +63,5 @@ def create_uipath_deep_agent( interrupt_on=interrupt_on, response_format=response_format, context_schema=context_schema, - checkpointer=checkpointer, - store=store, - debug=debug, - name=name, - cache=cache, ) return mark_uipath_deep_agent(graph) diff --git a/src/uipath_langchain/runtime/factory.py b/src/uipath_langchain/runtime/factory.py index 612bd52d2..0ee6de0ba 100644 --- a/src/uipath_langchain/runtime/factory.py +++ b/src/uipath_langchain/runtime/factory.py @@ -8,7 +8,6 @@ from langchain_core.callbacks import BaseCallbackHandler from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver from langgraph.graph.state import CompiledStateGraph, StateGraph -from langgraph.types import Checkpointer from openinference.instrumentation.langchain import ( LangChainInstrumentor, get_ancestor_spans, @@ -195,7 +194,7 @@ async def _load_graph( async def _compile_graph( self, graph: StateGraph[Any, Any, Any] | CompiledStateGraph[Any, Any, Any, Any], - memory: Checkpointer, + memory: AsyncSqliteSaver, ) -> CompiledStateGraph[Any, Any, Any, Any]: """ Compile a graph with the given memory/checkpointer. @@ -213,7 +212,7 @@ async def _compile_graph( return compiled.with_config(bound_config) if bound_config else compiled async def _resolve_and_compile_graph( - self, entrypoint: str, memory: Checkpointer, **kwargs + self, entrypoint: str, memory: AsyncSqliteSaver, **kwargs ) -> CompiledStateGraph[Any, Any, Any, Any]: """ Resolve a graph from configuration and compile it. diff --git a/tests/deepagents/test_agent.py b/tests/deepagents/test_agent.py index 62247e8aa..fc5a8ee66 100644 --- a/tests/deepagents/test_agent.py +++ b/tests/deepagents/test_agent.py @@ -23,9 +23,6 @@ def test_api_forwards_upstream_contract_with_uipath_backend() -> None: subagent = MagicMock() response_format = MagicMock() context_schema = MagicMock() - checkpointer = MagicMock() - store = MagicMock() - cache = MagicMock() with patch( "uipath_langchain.deepagents.agent._create_deep_agent", @@ -43,11 +40,6 @@ def test_api_forwards_upstream_contract_with_uipath_backend() -> None: interrupt_on={"write_file": True}, response_format=response_format, context_schema=context_schema, - checkpointer=checkpointer, - store=store, - debug=True, - name="agent", - cache=cache, ) kwargs = upstream.call_args.kwargs @@ -62,10 +54,10 @@ def test_api_forwards_upstream_contract_with_uipath_backend() -> None: assert kwargs["interrupt_on"] == {"write_file": True} assert kwargs["response_format"] is response_format assert kwargs["context_schema"] is context_schema - assert kwargs["checkpointer"] is checkpointer - assert kwargs["store"] is store - assert kwargs["debug"] is True - assert kwargs["name"] == "agent" - assert kwargs["cache"] is cache + 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_metadata.py b/tests/deepagents/test_metadata.py index eea91f38c..9fb5082a5 100644 --- a/tests/deepagents/test_metadata.py +++ b/tests/deepagents/test_metadata.py @@ -14,11 +14,12 @@ def _model() -> BaseChatModel: return model -def test_uipath_api_matches_upstream_parameters_except_backend() -> None: +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 != "backend"] + 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 diff --git a/tests/deepagents/test_runtime_factory.py b/tests/deepagents/test_runtime_factory.py index 751d5886c..faf4f7909 100644 --- a/tests/deepagents/test_runtime_factory.py +++ b/tests/deepagents/test_runtime_factory.py @@ -1,11 +1,17 @@ import hashlib +from pathlib import Path from typing import Any, TypedDict from unittest.mock import AsyncMock, MagicMock, patch -from uuid import uuid4 +from uuid import UUID, uuid4 from langgraph.checkpoint.memory import InMemorySaver from langgraph.graph import END, START, StateGraph -from uipath.runtime import HydrationPolicy, HydrationRuntime, UiPathRuntimeContext +from uipath.runtime import ( + HydrationPolicy, + HydrationRuntime, + UiPathRuntimeContext, + UiPathRuntimeResult, +) from uipath_langchain.deepagents.metadata import ( is_uipath_deep_agent, @@ -19,6 +25,54 @@ 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) @@ -115,6 +169,59 @@ async def test_marked_deep_agent_runtime_uses_hydrated_workspace(tmp_path) -> No 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.attachments = attachments + first.hydrator.jobs = None + first.registry_store = 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.attachments = attachments + resumed.hydrator.jobs = None + resumed.registry_store = 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) From 79c2666c5fc3f4c5632f3f42f56e4f0a5ca7fa2f Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Fri, 17 Jul 2026 12:41:09 +0300 Subject: [PATCH 4/5] refactor: use runtime hydrator factory --- pyproject.toml | 4 +- samples/coded-deepagent/README.md | 1 + src/uipath_langchain/deepagents/agent.py | 3 +- src/uipath_langchain/runtime/_workspace.py | 79 ---------------------- src/uipath_langchain/runtime/factory.py | 21 +++--- tests/deepagents/test_runtime_factory.py | 40 ++++++----- uv.lock | 10 +-- 7 files changed, 43 insertions(+), 115 deletions(-) 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/coded-deepagent/README.md b/samples/coded-deepagent/README.md index e68f0cb92..35bce71e5 100644 --- a/samples/coded-deepagent/README.md +++ b/samples/coded-deepagent/README.md @@ -40,6 +40,7 @@ uv run uipath auth ## Usage ```bash +uv run uipath init uv run uipath run agent "$(cat input.json)" ``` diff --git a/src/uipath_langchain/deepagents/agent.py b/src/uipath_langchain/deepagents/agent.py index f27f2e1f7..ba7693e91 100644 --- a/src/uipath_langchain/deepagents/agent.py +++ b/src/uipath_langchain/deepagents/agent.py @@ -26,8 +26,7 @@ # This API intentionally omits DeepAgents' compile-time arguments. # UiPath-compatible DeepAgents use the runtime-managed filesystem backend and -# checkpointer. Other compile-time settings are unsupported because the runtime -# recompiles entrypoints with its managed configuration. +# 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, diff --git a/src/uipath_langchain/runtime/_workspace.py b/src/uipath_langchain/runtime/_workspace.py index 4d74d404c..4cfcbb5f3 100644 --- a/src/uipath_langchain/runtime/_workspace.py +++ b/src/uipath_langchain/runtime/_workspace.py @@ -2,10 +2,8 @@ from pathlib import Path from typing import Any, NotRequired, TypedDict -from uuid import UUID from langchain_core.runnables import RunnableConfig -from uipath.platform import UiPath WORKSPACE_PATH_CONFIG_KEY = "uipath_workspace_path" @@ -43,80 +41,3 @@ def get_workspace_path(config: RunnableConfig) -> Path: "by create_uipath_deep_agent through the UiPath runtime." ) return Path(workspace_path) - - -class LazyUiPathWorkspaceServices: - """Resolve UiPath attachment and job services on first remote operation.""" - - def __init__(self) -> None: - self._sdk: UiPath | None = None - - def _get_sdk(self) -> UiPath: - if self._sdk is None: - self._sdk = UiPath() - return self._sdk - - async def download_async( - self, - *, - key: UUID, - destination_path: str, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> str: - return await self._get_sdk().attachments.download_async( - key=key, - destination_path=destination_path, - folder_key=folder_key, - folder_path=folder_path, - ) - - 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: - upload: dict[str, Any] = { - "name": name, - "folder_key": folder_key, - "folder_path": folder_path, - } - if source_path is not None: - upload["source_path"] = source_path - elif content is not None: - upload["content"] = content - else: - raise ValueError("Workspace attachment upload requires content or a path") - return await self._get_sdk().attachments.upload_async(**upload) - - async def list_attachments_async( - self, - *, - job_key: UUID, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> list[str]: - return await self._get_sdk().jobs.list_attachments_async( - job_key=job_key, - folder_key=folder_key, - folder_path=folder_path, - ) - - async def link_attachment_async( - self, - *, - job_key: UUID, - attachment_key: UUID, - folder_key: str | None = None, - folder_path: str | None = None, - ) -> None: - await self._get_sdk().jobs.link_attachment_async( - job_key=job_key, - attachment_key=attachment_key, - folder_key=folder_key, - folder_path=folder_path, - ) diff --git a/src/uipath_langchain/runtime/factory.py b/src/uipath_langchain/runtime/factory.py index 0ee6de0ba..0f0cf554a 100644 --- a/src/uipath_langchain/runtime/factory.py +++ b/src/uipath_langchain/runtime/factory.py @@ -15,6 +15,7 @@ ) 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, ) @@ -37,7 +38,6 @@ is_uipath_deep_agent, ) from uipath_langchain.governance import GovernanceCallbackHandler -from uipath_langchain.runtime._workspace import LazyUiPathWorkspaceServices from uipath_langchain.runtime.config import LangGraphConfig from uipath_langchain.runtime.errors import LangGraphErrorCode, LangGraphRuntimeError from uipath_langchain.runtime.graph import LangGraphLoader @@ -337,17 +337,20 @@ async def _create_runtime_instance( if workspace is None: return resumable_runtime - services = LazyUiPathWorkspaceServices() - return HydrationRuntime( - delegate=resumable_runtime, - workspace=workspace, - hydrator=WorkspaceHydrator( + def create_hydrator() -> WorkspaceHydrator: + sdk = UiPath() + return WorkspaceHydrator( workspace_path=workspace.path, - attachments=services, - jobs=services, + 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, diff --git a/tests/deepagents/test_runtime_factory.py b/tests/deepagents/test_runtime_factory.py index faf4f7909..0c4bc0935 100644 --- a/tests/deepagents/test_runtime_factory.py +++ b/tests/deepagents/test_runtime_factory.py @@ -1,23 +1,24 @@ import hashlib from pathlib import Path -from typing import Any, TypedDict +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from uuid import UUID, uuid4 from langgraph.checkpoint.memory import InMemorySaver from langgraph.graph import END, START, StateGraph +from typing_extensions import TypedDict from uipath.runtime import ( HydrationPolicy, HydrationRuntime, UiPathRuntimeContext, UiPathRuntimeResult, + WorkspaceHydrator, ) from uipath_langchain.deepagents.metadata import ( is_uipath_deep_agent, mark_uipath_deep_agent, ) -from uipath_langchain.runtime._workspace import LazyUiPathWorkspaceServices from uipath_langchain.runtime.factory import UiPathLangGraphRuntimeFactory @@ -135,7 +136,7 @@ async def test_marked_deep_agent_runtime_uses_hydrated_workspace(tmp_path) -> No with ( patch( - "uipath_langchain.runtime._workspace.UiPath", return_value=sdk + "uipath_langchain.runtime.factory.UiPath", return_value=sdk ) as sdk_constructor, patch.object(factory, "_get_memory", AsyncMock(return_value=MagicMock())), ): @@ -144,12 +145,10 @@ async def test_marked_deep_agent_runtime_uses_hydrated_workspace(tmp_path) -> No runtime_id="runtime-1", entrypoint="agent", ) + await runtime.get_schema() sdk_constructor.assert_not_called() - services = runtime.hydrator.attachments - assert isinstance(services, LazyUiPathWorkspaceServices) - await services.download_async( - key=uuid4(), destination_path=str(tmp_path / "downloaded") - ) + assert runtime.hydrator is None + hydrator = runtime._get_hydrator() sdk_constructor.assert_called_once_with() assert isinstance(runtime, HydrationRuntime) @@ -161,11 +160,12 @@ async def test_marked_deep_agent_runtime_uses_hydrated_workspace(tmp_path) -> No langgraph_runtime = resumable_runtime.delegate assert langgraph_runtime.workspace_path == runtime.workspace.path - assert runtime.hydrator.workspace_path == runtime.workspace.path - assert runtime.hydrator.jobs is runtime.hydrator.attachments - assert runtime.hydrator.current_job_key == "job-1" - assert runtime.hydrator.folder_key == "folder-1" - assert runtime.hydrator.attachment_prefix == ".uipath-workspace" + 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" @@ -185,8 +185,10 @@ async def test_workspace_is_persisted_and_rehydrated_before_resumed_execution( entrypoint="agent", ) assert isinstance(first, HydrationRuntime) - first.hydrator.attachments = attachments - first.hydrator.jobs = None + first.hydrator = WorkspaceHydrator( + workspace_path=first.workspace.path, + attachments=attachments, + ) first.registry_store = registry workspace_file = first.workspace.path / "notes" / "plan.txt" @@ -206,8 +208,10 @@ async def write_workspace(*args, **kwargs) -> UiPathRuntimeResult: entrypoint="agent", ) assert isinstance(resumed, HydrationRuntime) - resumed.hydrator.attachments = attachments - resumed.hydrator.jobs = None + resumed.hydrator = WorkspaceHydrator( + workspace_path=resumed.workspace.path, + attachments=attachments, + ) resumed.registry_store = registry resumed_file = resumed.workspace.path / "notes" / "plan.txt" assert not resumed_file.exists() @@ -228,7 +232,7 @@ async def test_plain_graph_runtime_is_not_hydrated(tmp_path) -> None: with ( patch( - "uipath_langchain.runtime._workspace.UiPath", + "uipath_langchain.runtime.factory.UiPath", side_effect=AssertionError("UiPath SDK must remain lazy"), ), patch.object(factory, "_get_memory", AsyncMock(return_value=MagicMock())), 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]] From 3b3cf2c67c9b3f3ece6e5b30a169ad0698497b14 Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Fri, 17 Jul 2026 12:53:03 +0300 Subject: [PATCH 5/5] test: fix DeepAgents static typing --- tests/deepagents/test_agent.py | 7 ++++- tests/deepagents/test_backend.py | 6 ++-- tests/deepagents/test_runtime_factory.py | 37 +++++++++++++++++------- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/tests/deepagents/test_agent.py b/tests/deepagents/test_agent.py index fc5a8ee66..2df61e4b9 100644 --- a/tests/deepagents/test_agent.py +++ b/tests/deepagents/test_agent.py @@ -1,14 +1,19 @@ 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(dict) + builder = StateGraph(_State) builder.add_node("noop", lambda state: state) builder.add_edge(START, "noop") builder.add_edge("noop", END) diff --git a/tests/deepagents/test_backend.py b/tests/deepagents/test_backend.py index 04c2bb3ef..80b96836c 100644 --- a/tests/deepagents/test_backend.py +++ b/tests/deepagents/test_backend.py @@ -1,18 +1,18 @@ from types import SimpleNamespace -from typing import Any 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: dict[str, Any]) -> ToolRuntime: +def _tool_runtime(config: RunnableConfig) -> ToolRuntime: return ToolRuntime( - state=None, + state={}, context=None, config=config, stream_writer=lambda _: None, diff --git a/tests/deepagents/test_runtime_factory.py b/tests/deepagents/test_runtime_factory.py index 0c4bc0935..1961faa6f 100644 --- a/tests/deepagents/test_runtime_factory.py +++ b/tests/deepagents/test_runtime_factory.py @@ -1,18 +1,21 @@ import hashlib from pathlib import Path -from typing import Any +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 ( @@ -20,6 +23,7 @@ mark_uipath_deep_agent, ) from uipath_langchain.runtime.factory import UiPathLangGraphRuntimeFactory +from uipath_langchain.runtime.runtime import UiPathLangGraphRuntime class _State(TypedDict): @@ -82,6 +86,10 @@ def _build_graph() -> StateGraph[Any, Any, Any]: 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) @@ -97,12 +105,14 @@ async def test_runtime_recompilation_preserves_bound_graph_config(tmp_path) -> N ) ) - result = await factory._compile_graph(loaded, InMemorySaver()) + result = await factory._compile_graph(loaded, _test_checkpointer()) assert is_uipath_deep_agent(result) - assert result.config["recursion_limit"] == 9999 - assert result.config["tags"] == ["bound-tag"] - assert result.config["metadata"] == { + 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", } @@ -114,8 +124,8 @@ async def test_resolved_deep_agent_graph_keeps_marker_when_cached(tmp_path) -> N 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", InMemorySaver()) - second = await factory._resolve_and_compile_graph("agent", InMemorySaver()) + 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) @@ -145,19 +155,21 @@ async def test_marked_deep_agent_runtime_uses_hydrated_workspace(tmp_path) -> No 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 isinstance(runtime, HydrationRuntime) 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 @@ -189,7 +201,7 @@ async def test_workspace_is_persisted_and_rehydrated_before_resumed_execution( workspace_path=first.workspace.path, attachments=attachments, ) - first.registry_store = registry + first.registry_store = cast(WorkspaceRegistryStore, registry) workspace_file = first.workspace.path / "notes" / "plan.txt" async def write_workspace(*args, **kwargs) -> UiPathRuntimeResult: @@ -212,7 +224,7 @@ async def write_workspace(*args, **kwargs) -> UiPathRuntimeResult: workspace_path=resumed.workspace.path, attachments=attachments, ) - resumed.registry_store = registry + resumed.registry_store = cast(WorkspaceRegistryStore, registry) resumed_file = resumed.workspace.path / "notes" / "plan.txt" assert not resumed_file.exists() @@ -244,7 +256,10 @@ async def test_plain_graph_runtime_is_not_hydrated(tmp_path) -> None: ) assert not isinstance(runtime, HydrationRuntime) - assert runtime.delegate.workspace_path is None + 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()