diff --git a/samples/README.md b/samples/README.md index ade8b2c1e..a628be9c8 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, typed input/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..9e51a994c --- /dev/null +++ b/samples/coded-deepagent/README.md @@ -0,0 +1,39 @@ +# Coded DeepAgent + +This sample demonstrates a task-mode coded agent built directly with the +standard `deepagents.create_deep_agent` API. + +The sample does not use a UiPath-specific graph factory or attach UiPath +metadata. At runtime, UiPath detects the `ls_integration: deepagents` metadata +already present on the graph and applies the UiPath-owned runtime policy. + +## 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. +- Automatic runtime detection through native DeepAgents metadata. + +## Files + +- `graph.py`: task-mode coded DeepAgent graph. +- `input.json`: sample input payload. +- `langgraph.json`: LangGraph entrypoint. +- `uipath.json`: UiPath task-mode runtime configuration. +- `agent.mermaid`: high-level graph diagram. + +## Requirements + +- UiPath runtime credentials for `UiPathChat`. +- Access to the configured model, `gpt-4o-2024-08-06`. + +## Run + +```bash +cd samples/coded-deepagent +uv sync +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..5bec4cbef --- /dev/null +++ b/samples/coded-deepagent/agent.mermaid @@ -0,0 +1,15 @@ +flowchart TB + __start__(__start__) + transform_input(transform_input) + advanced_agent(advanced_agent) + risk_reviewer([risk_reviewer subagent]) + workspace[(runtime workspace)] + transform_output(transform_output) + __end__(__end__) + + __start__ --> transform_input + transform_input --> advanced_agent + advanced_agent -. delegates review .-> risk_reviewer + advanced_agent -. writes files .-> workspace + advanced_agent --> transform_output + transform_output --> __end__ diff --git a/samples/coded-deepagent/graph.py b/samples/coded-deepagent/graph.py new file mode 100644 index 000000000..ca23f1852 --- /dev/null +++ b/samples/coded-deepagent/graph.py @@ -0,0 +1,66 @@ +"""Task-mode coded DeepAgent using the standard DeepAgents API.""" + +from deepagents import create_deep_agent +from langchain_core.tools import tool +from pydantic import BaseModel + +from uipath_langchain.chat import UiPathChat + + +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-4o-2024-08-06", 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_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..77c0692d7 --- /dev/null +++ b/samples/coded-deepagent/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "coded-deepagent" +version = "0.0.1" +description = "Task-mode coded DeepAgent using the UiPath runtime workspace contract" +authors = [{ name = "UiPath", email = "support@uipath.com" }] +requires-python = ">=3.11" +dependencies = [ + "uipath-langchain>=0.14.3, <0.15.0", + "uipath>=2.13.6, <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/agent/advanced/agent.py b/src/uipath_langchain/agent/advanced/agent.py index 4baaf1f0b..cfcfc2e65 100644 --- a/src/uipath_langchain/agent/advanced/agent.py +++ b/src/uipath_langchain/agent/advanced/agent.py @@ -6,11 +6,11 @@ from deepagents import CompiledSubAgent, SubAgent from deepagents import create_deep_agent as _create_deep_agent from deepagents.backends import BackendProtocol -from deepagents.backends.filesystem import FilesystemBackend from deepagents.backends.protocol import BackendFactory from langchain.agents.structured_output import ResponseFormat from langchain_core.language_models import BaseChatModel -from langchain_core.messages import HumanMessage +from langchain_core.messages import HumanMessage, SystemMessage +from langchain_core.runnables.config import RunnableConfig from langchain_core.tools import BaseTool from langgraph.graph import END, START from langgraph.graph.state import CompiledStateGraph, StateGraph @@ -23,6 +23,7 @@ from .utils import ( MEMORY_INDEX_VIRTUAL_PATH, create_state_with_input, + is_workspace_filesystem_backend, resolve_input_attachments, ) @@ -62,22 +63,25 @@ def create_advanced_agent_graph( input_schema: type[BaseModel] | None, output_schema: type[BaseModel], build_user_message: Callable[[dict[str, Any]], str], + build_system_message: Callable[[dict[str, Any]], str] | None = None, + subagents: Sequence[SubAgent | CompiledSubAgent] = (), ) -> StateGraph[Any, Any, Any, Any]: """Wrap the advanced agent in a parent graph that maps typed I/O to/from messages. - With a ``FilesystemBackend``, attachment-shaped inputs are downloaded into the - workspace and given a ``FilePath`` before the user message is built. A - ``FilesystemBackend`` also enables workspace memory: deepagents' + With a filesystem workspace backend, attachment-shaped inputs are downloaded + into the workspace and given a ``FilePath`` before the user message is built. + Filesystem workspaces also enable workspace memory: DeepAgents' ``MemoryMiddleware`` reads ``/memory/MEMORY.md`` from the backend each turn. - Memory stays disabled for non-filesystem backends, which carry no workspace. + Memory stays disabled for backends which carry no workspace. """ memory_sources = ( - [MEMORY_INDEX_VIRTUAL_PATH] if isinstance(backend, FilesystemBackend) else [] + [MEMORY_INDEX_VIRTUAL_PATH] if is_workspace_filesystem_backend(backend) else [] ) inner_graph = create_advanced_agent( model=model, tools=tools, + subagents=subagents, system_prompt=system_prompt, backend=backend, response_format=response_format, @@ -90,7 +94,10 @@ def create_advanced_agent_graph( get_job_attachment_paths(input_schema) if input_schema is not None else [] ) - async def transform_input_async(state: BaseModel) -> dict[str, Any]: + async def transform_input_async( + state: BaseModel, + config: RunnableConfig, + ) -> dict[str, Any]: state_data = state.model_dump() input_data = {k: v for k, v in state_data.items() if k not in internal_fields} input_args = ( @@ -100,10 +107,20 @@ async def transform_input_async(state: BaseModel) -> dict[str, Any]: ) if attachment_paths: input_args = await resolve_input_attachments( - backend, attachment_paths, input_args + backend, + attachment_paths, + input_args, + state=state, + config=config, ) + messages = [] + if build_system_message is not None: + system_text = build_system_message(input_args) + if system_text: + messages.append(SystemMessage(content=system_text, id="system-input")) user_text = build_user_message(input_args) - return {"messages": [HumanMessage(content=user_text, id="user-input")]} + messages.append(HumanMessage(content=user_text, id="user-input")) + return {"messages": messages} def transform_output(state: BaseModel) -> dict[str, Any]: structured = getattr(state, "structured_response", {}) @@ -128,6 +145,7 @@ def create_conversational_advanced_agent_graph( tools: Sequence[BaseTool], system_prompt: str, backend: BackendProtocol | BackendFactory | None, + subagents: Sequence[SubAgent | CompiledSubAgent] = (), ) -> StateGraph[Any, Any, Any, Any]: """Wrap the advanced agent in a parent graph that speaks the conversational contract. @@ -141,12 +159,13 @@ def create_conversational_advanced_agent_graph( from uipath_langchain.runtime.messages import UiPathChatMessagesMapper memory_sources = ( - [MEMORY_INDEX_VIRTUAL_PATH] if isinstance(backend, FilesystemBackend) else [] + [MEMORY_INDEX_VIRTUAL_PATH] if is_workspace_filesystem_backend(backend) else [] ) inner_graph = create_advanced_agent( model=model, tools=tools, + subagents=subagents, system_prompt=system_prompt, backend=backend, memory=memory_sources, diff --git a/src/uipath_langchain/agent/advanced/utils.py b/src/uipath_langchain/agent/advanced/utils.py index 0726bf5d2..1aea07cd5 100644 --- a/src/uipath_langchain/agent/advanced/utils.py +++ b/src/uipath_langchain/agent/advanced/utils.py @@ -10,6 +10,8 @@ from deepagents.backends import BackendProtocol, FilesystemBackend from deepagents.backends.protocol import BackendFactory from jsonpath_ng import parse as jsonpath_parse # type: ignore[import-untyped] +from langchain.tools import ToolRuntime +from langchain_core.runnables.config import RunnableConfig from pydantic import BaseModel from uipath.platform import UiPath from uipath.platform.attachments import Attachment @@ -25,12 +27,54 @@ # persisted via WorkspaceHydrator) rather than the cross-run StoreBackend. MEMORY_DIR_NAME = "memory" MEMORY_INDEX_FILENAME = "MEMORY.md" +WORKSPACE_FILESYSTEM_BACKEND_ATTR = "is_uipath_workspace_filesystem_backend" # Virtual path handed to MemoryMiddleware as a source; the agent's virtual-mode # FilesystemBackend resolves it under the workspace root. MEMORY_INDEX_VIRTUAL_PATH = f"/{MEMORY_DIR_NAME}/{MEMORY_INDEX_FILENAME}" +def is_workspace_filesystem_backend( + backend: BackendProtocol | BackendFactory | None, +) -> bool: + """Return whether a backend is backed by a runtime workspace filesystem.""" + return isinstance(backend, FilesystemBackend) or ( + callable(backend) + and getattr(backend, WORKSPACE_FILESYSTEM_BACKEND_ATTR, False) is True + ) + + +def _resolve_filesystem_backend( + backend: BackendProtocol | BackendFactory | None, + *, + state: BaseModel | None = None, + config: RunnableConfig | None = None, +) -> FilesystemBackend: + if isinstance(backend, FilesystemBackend): + return backend + if callable(backend) and is_workspace_filesystem_backend(backend): + resolved = backend( + ToolRuntime( + state=state, + context=None, + config=config or {}, + stream_writer=lambda _: None, + tool_call_id=None, + store=None, + ) + ) + if isinstance(resolved, FilesystemBackend): + return resolved + raise TypeError( + "UiPath workspace backend factory must resolve to FilesystemBackend, " + f"got {type(resolved).__name__}" + ) + raise NotImplementedError( + "Advanced agent with input attachments requires a FilesystemBackend, " + f"got {type(backend).__name__}" + ) + + def create_state_with_input( input_schema: type[BaseModel] | None, ) -> type[AdvancedAgentGraphState]: @@ -59,17 +103,16 @@ async def resolve_input_attachments( backend: BackendProtocol | BackendFactory | None, attachment_paths: list[str], input_args: dict[str, Any], + *, + state: BaseModel | None = None, + config: RunnableConfig | None = None, ) -> dict[str, Any]: """Download attachment-shaped inputs into the backend and add a ``FilePath``. Each ticket is streamed to ``/_`` and augmented with a - ``FilePath`` so the agent's file tools can open it. FilesystemBackend only. + ``FilePath`` so the agent's file tools can open it. """ - if not isinstance(backend, FilesystemBackend): - raise NotImplementedError( - "Advanced agent with input attachments requires a FilesystemBackend, " - f"got {type(backend).__name__}" - ) + backend = _resolve_filesystem_backend(backend, state=state, config=config) result = copy.deepcopy(input_args) client = UiPath() diff --git a/src/uipath_langchain/deepagents/__init__.py b/src/uipath_langchain/deepagents/__init__.py new file mode 100644 index 000000000..3bdb1138c --- /dev/null +++ b/src/uipath_langchain/deepagents/__init__.py @@ -0,0 +1 @@ +"""Internal DeepAgents runtime support for UiPath.""" diff --git a/src/uipath_langchain/deepagents/backend.py b/src/uipath_langchain/deepagents/backend.py new file mode 100644 index 000000000..77b6c824c --- /dev/null +++ b/src/uipath_langchain/deepagents/backend.py @@ -0,0 +1,54 @@ +"""Workspace backend helpers for UiPath DeepAgents.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from deepagents.backends.filesystem import FilesystemBackend +from deepagents.backends.protocol import BackendFactory +from langchain.tools import ToolRuntime +from langgraph.config import get_config + +WORKSPACE_FILESYSTEM_BACKEND_ATTR = "is_uipath_workspace_filesystem_backend" + + +@dataclass(frozen=True) +class UiPathWorkspaceBackendFactory: + """DeepAgents backend factory resolved from UiPath runtime config.""" + + workspace_config_key: str = "uipath_workspace_path" + virtual_mode: bool = True + is_uipath_workspace_filesystem_backend: bool = field(default=True, init=False) + + 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(self.workspace_config_key) + if not workspace_path: + raise RuntimeError( + f"UiPath DeepAgents workspace path missing from config key " + f"'{self.workspace_config_key}'." + ) + return FilesystemBackend( + root_dir=Path(workspace_path), virtual_mode=self.virtual_mode + ) + + +def create_workspace_backend_factory( + *, + workspace_config_key: str = "uipath_workspace_path", + virtual_mode: bool = True, +) -> BackendFactory: + """Create a DeepAgents backend factory bound to the runtime workspace path.""" + return UiPathWorkspaceBackendFactory( + workspace_config_key=workspace_config_key, + virtual_mode=virtual_mode, + ) diff --git a/src/uipath_langchain/deepagents/metadata.py b/src/uipath_langchain/deepagents/metadata.py new file mode 100644 index 000000000..c9eb2adc0 --- /dev/null +++ b/src/uipath_langchain/deepagents/metadata.py @@ -0,0 +1,43 @@ +"""DeepAgents graph detection for the UiPath runtime.""" + +from __future__ import annotations + +from typing import Any + +_DEEPAGENTS_INTEGRATION_NAME = "deepagents" + + +def is_deep_agent_graph(graph: Any) -> bool: + """Return whether a graph-like object carries DeepAgents LangSmith metadata.""" + return _contains_deepagents_metadata(graph, seen=set()) + + +def _contains_deepagents_metadata(graph: Any, seen: set[int]) -> bool: + graph_id = id(graph) + if graph_id in seen: + return False + seen.add(graph_id) + + if _has_deepagents_metadata(graph): + return True + + builder = getattr(graph, "builder", None) + if builder is not None and _contains_deepagents_metadata(builder, seen): + return True + + nodes = getattr(graph, "nodes", None) + if isinstance(nodes, dict): + for node in nodes.values(): + if _contains_deepagents_metadata(node, seen): + return True + runnable = getattr(node, "runnable", None) + if runnable is not None and _contains_deepagents_metadata(runnable, seen): + return True + + return False + + +def _has_deepagents_metadata(graph: Any) -> bool: + config = getattr(graph, "config", None) or {} + metadata = config.get("metadata", {}) if isinstance(config, dict) else {} + return metadata.get("ls_integration") == _DEEPAGENTS_INTEGRATION_NAME diff --git a/src/uipath_langchain/runtime/factory.py b/src/uipath_langchain/runtime/factory.py index 9d16137b5..83938586d 100644 --- a/src/uipath_langchain/runtime/factory.py +++ b/src/uipath_langchain/runtime/factory.py @@ -1,5 +1,6 @@ import asyncio import os +from pathlib import Path from typing import Any, AsyncContextManager from langchain_core.callbacks import BaseCallbackHandler @@ -12,19 +13,26 @@ ) from uipath.core.adapters import EvaluatorProtocol from uipath.core.tracing import UiPathSpanUtils, UiPathTraceManager +from uipath.platform import UiPath from uipath.platform.resume_triggers import ( UiPathResumeTriggerHandler, ) from uipath.runtime import ( + HydrationPolicy, + HydrationRuntime, UiPathResumableRuntime, UiPathRuntimeContext, UiPathRuntimeFactorySettings, UiPathRuntimeProtocol, UiPathRuntimeStorageProtocol, + Workspace, + WorkspaceHydrator, + WorkspaceRegistryStore, ) from uipath.runtime.errors import UiPathErrorCategory from uipath_langchain._tracing import _instrument_traceable_attributes +from uipath_langchain.deepagents.metadata import is_deep_agent_graph from uipath_langchain.governance import GovernanceCallbackHandler from uipath_langchain.runtime.config import LangGraphConfig from uipath_langchain.runtime.errors import LangGraphErrorCode, LangGraphRuntimeError @@ -34,6 +42,9 @@ _AGENT_TYPE_CODED = "uipath_coded" _AGENT_FRAMEWORK = "langchain" +_DEEPAGENT_WORKSPACE_CONFIG_KEY = "uipath_workspace_path" +_DEEPAGENT_ATTACHMENT_PREFIX = ".uipath-workspace" +_DEEPAGENT_HYDRATION_POLICY = HydrationPolicy.SUSPEND_OR_SUCCESS class UiPathLangGraphRuntimeFactory: @@ -56,6 +67,7 @@ def __init__( self._memory_lock = asyncio.Lock() self._graph_cache: dict[str, CompiledStateGraph[Any, Any, Any, Any]] = {} + self._deep_agent_entrypoints: set[str] = set() self._graph_loaders: dict[str, LangGraphLoader] = {} self._graph_lock = asyncio.Lock() @@ -220,6 +232,8 @@ async def _resolve_and_compile_graph( return self._graph_cache[entrypoint] loaded_graph = await self._load_graph(entrypoint, **kwargs) + if is_deep_agent_graph(loaded_graph): + self._deep_agent_entrypoints.add(entrypoint) compiled_graph = await self._compile_graph(loaded_graph, memory) @@ -300,21 +314,66 @@ async def _create_runtime_instance( else None ) + is_deep_agent = ( + entrypoint in self._deep_agent_entrypoints + or is_deep_agent_graph(compiled_graph) + ) + workspace = self._create_deep_agent_workspace(runtime_id, is_deep_agent) + configurable = ( + {_DEEPAGENT_WORKSPACE_CONFIG_KEY: str(workspace.path)} + if workspace is not None + else None + ) + base_runtime = UiPathLangGraphRuntime( graph=compiled_graph, runtime_id=runtime_id, entrypoint=entrypoint, callbacks=callbacks, storage=storage, + configurable=configurable, ) - 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 + + sdk = UiPath() + return HydrationRuntime( + delegate=resumable_runtime, + workspace=workspace, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=sdk.attachments, + jobs=getattr(sdk, "jobs", None), + current_job_key=self.context.job_id, + folder_key=self.context.folder_key, + attachment_prefix=_DEEPAGENT_ATTACHMENT_PREFIX, + ), + registry_store=WorkspaceRegistryStore( + storage, + runtime_id, + ), + policy=_DEEPAGENT_HYDRATION_POLICY, + ) + + def _create_deep_agent_workspace( + self, + runtime_id: str, + is_deep_agent: bool, + ) -> Workspace | None: + if not is_deep_agent: + return None + + base_dir = Path(self.context.runtime_dir or "__uipath") / "workspaces" + return Workspace.create(base_dir / runtime_id, cleanup=True) + async def new_runtime( self, entrypoint: str, @@ -356,6 +415,7 @@ async def dispose(self) -> None: self._graph_loaders.clear() self._graph_cache.clear() + self._deep_agent_entrypoints.clear() if self._memory_cm is not None: await self._memory_cm.__aexit__(None, None, None) diff --git a/src/uipath_langchain/runtime/runtime.py b/src/uipath_langchain/runtime/runtime.py index 04657b1e7..86ffdd299 100644 --- a/src/uipath_langchain/runtime/runtime.py +++ b/src/uipath_langchain/runtime/runtime.py @@ -91,6 +91,7 @@ def __init__( entrypoint: str | None = None, callbacks: list[BaseCallbackHandler] | None = None, storage: UiPathRuntimeStorageProtocol | None = None, + configurable: dict[str, Any] | None = None, ): """ Initialize the runtime. @@ -104,6 +105,7 @@ def __init__( self.runtime_id: str = runtime_id or "default" self.entrypoint: str | None = entrypoint self.callbacks: list[BaseCallbackHandler] = callbacks or [] + self.configurable = configurable or {} 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 +351,7 @@ 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": {"thread_id": self.runtime_id, **self.configurable}, "callbacks": self.callbacks, } diff --git a/tests/agent/advanced/test_create_advanced_agent_graph.py b/tests/agent/advanced/test_create_advanced_agent_graph.py index 4a788d6b0..2a69cdc2f 100644 --- a/tests/agent/advanced/test_create_advanced_agent_graph.py +++ b/tests/agent/advanced/test_create_advanced_agent_graph.py @@ -5,12 +5,13 @@ import pytest from langchain_core.language_models import BaseChatModel -from langchain_core.messages import HumanMessage +from langchain_core.messages import HumanMessage, SystemMessage from pydantic import BaseModel, ConfigDict, Field from uipath_langchain.agent.advanced.agent import create_advanced_agent_graph from uipath_langchain.agent.advanced.types import AdvancedAgentGraphState from uipath_langchain.agent.advanced.utils import create_state_with_input +from uipath_langchain.deepagents.metadata import is_deep_agent_graph class _Output(BaseModel): @@ -56,6 +57,27 @@ def test_wrapper_graph_has_io_nodes() -> None: assert {"transform_input", "advanced_agent", "transform_output"} <= set(graph.nodes) +def test_wrapper_is_detected_from_nested_deepagents_metadata() -> None: + assert is_deep_agent_graph(_build()) + + +def test_subagents_are_passed_to_inner_advanced_agent() -> None: + """The typed I/O wrapper preserves DeepAgents sub-agent delegation.""" + subagent = { + "name": "researcher", + "description": "Researches one part of the task", + "system_prompt": "Research carefully.", + } + + with patch( + "uipath_langchain.agent.advanced.agent.create_advanced_agent", + return_value=MagicMock(), + ) as mock_create: + _build(subagents=[subagent]) + + assert mock_create.call_args.kwargs["subagents"] == [subagent] + + @pytest.mark.asyncio async def test_transform_input_without_schema_builds_single_user_message() -> None: """With no input schema, the built message comes straight from build_user_message.""" @@ -69,6 +91,41 @@ async def test_transform_input_without_schema_builds_single_user_message() -> No assert message.id == "user-input" +@pytest.mark.asyncio +async def test_transform_input_includes_dynamic_system_message() -> None: + """Runtime input can render a system message before the user message.""" + graph = _build( + input_schema=_Input, + build_system_message=lambda args: f"system:{args['question']}", + build_user_message=lambda args: f"user:{args['question']}", + ) + state_cls = create_state_with_input(_Input) + state = state_cls(question="q") + + out = await graph.nodes["transform_input"].runnable.ainvoke(state) + + system_message, user_message = out["messages"] + assert isinstance(system_message, SystemMessage) + assert system_message.content == "system:q" + assert system_message.id == "system-input" + assert isinstance(user_message, HumanMessage) + assert user_message.content == "user:q" + assert user_message.id == "user-input" + + +@pytest.mark.asyncio +async def test_transform_input_omits_empty_dynamic_system_message() -> None: + """An empty rendered system prompt does not create a blank message.""" + graph = _build(build_system_message=lambda _args: "") + + out = await graph.nodes["transform_input"].runnable.ainvoke( + AdvancedAgentGraphState() + ) + + assert len(out["messages"]) == 1 + assert isinstance(out["messages"][0], HumanMessage) + + @pytest.mark.asyncio async def test_transform_input_resolves_attachments_when_present() -> None: """With an input schema and attachment paths, input attachments are resolved first.""" diff --git a/tests/agent/advanced/test_memory_injection.py b/tests/agent/advanced/test_memory_injection.py index 5a54e814e..e90051c42 100644 --- a/tests/agent/advanced/test_memory_injection.py +++ b/tests/agent/advanced/test_memory_injection.py @@ -1,11 +1,11 @@ -"""Wiring test: the wrapper enables deepagents memory based on the backend. +"""Wiring test: the wrapper enables DeepAgents memory based on the backend. Phase 1 workspace memory is delegated to deepagents' ``MemoryMiddleware``, which ``create_deep_agent`` builds when a ``memory=`` source list is supplied. The wrapper's responsibility is therefore to pass ``memory=["/memory/MEMORY.md"]`` through to ``_create_deep_agent`` when (and only when) the backend is a ``FilesystemBackend`` that carries a durable workspace. The actual loading and -system-prompt injection are deepagents' concern and covered by their own tests. +system-prompt injection are DeepAgents' concern and covered by their own tests. """ from typing import Any @@ -40,9 +40,7 @@ def _build_user_message(args: dict[str, Any]) -> str: return args.get("task", "") -def _memory_kwarg( - backend: Any, -) -> Any: +def _memory_kwarg(backend: Any) -> Any: """Build the wrapper graph and return the ``memory`` kwarg handed to deepagents.""" with patch( "uipath_langchain.agent.advanced.agent._create_deep_agent", @@ -62,15 +60,23 @@ def _memory_kwarg( class TestWorkspaceMemoryWiring: - """The wrapper turns deepagents memory on for filesystem-backed workspaces.""" + """The wrapper turns DeepAgents memory on for filesystem-backed workspaces.""" def test_enables_memory_for_filesystem_backend(self, tmp_path: Any) -> None: backend = FilesystemBackend(root_dir=tmp_path, virtual_mode=True) assert _memory_kwarg(backend) == [MEMORY_INDEX_VIRTUAL_PATH] + def test_enables_memory_for_marked_runtime_workspace_factory( + self, tmp_path: Any + ) -> None: + def backend_factory(runtime: Any) -> FilesystemBackend: + return FilesystemBackend(root_dir=tmp_path, virtual_mode=True) + + backend_factory.is_uipath_workspace_filesystem_backend = True # type: ignore[attr-defined] + + assert _memory_kwarg(backend_factory) == [MEMORY_INDEX_VIRTUAL_PATH] + def test_disables_memory_for_non_filesystem_backend(self) -> None: - # The default in-state backend (None) carries no durable workspace, so - # passing memory=None leaves MemoryMiddleware out of the stack entirely. assert _memory_kwarg(None) is None @@ -83,32 +89,14 @@ async def test_transform_input_emits_only_user_message(self, tmp_path: Any) -> N # message; memory injection is now MemoryMiddleware's job inside the inner # agent, not the wrapper's. from langchain_core.messages import HumanMessage - from langgraph.graph import END, START, StateGraph - - from uipath_langchain.agent.advanced.types import ( - AdvancedAgentGraphState, - ) memory_dir = tmp_path / "memory" memory_dir.mkdir() (memory_dir / "MEMORY.md").write_text("- entry: x", encoding="utf-8") - captured: list[list[Any]] = [] - - def _make_inner() -> Any: - def _capture(state: AdvancedAgentGraphState) -> dict[str, Any]: - captured.append(list(state.messages)) - return {"structured_response": {"result": "ok"}} - - inner = StateGraph(AdvancedAgentGraphState) - inner.add_node("capture", _capture) - inner.add_edge(START, "capture") - inner.add_edge("capture", END) - return inner.compile() - with patch( "uipath_langchain.agent.advanced.agent._create_deep_agent", - return_value=_make_inner(), + return_value=MagicMock(), ): wrapper = create_advanced_agent_graph( model=MagicMock(spec=BaseChatModel), @@ -119,11 +107,12 @@ def _capture(state: AdvancedAgentGraphState) -> dict[str, Any]: input_schema=_Input, output_schema=_Output, build_user_message=_build_user_message, - ).compile() - await wrapper.ainvoke({"task": "do something"}) + ) + out = await wrapper.nodes["transform_input"].runnable.ainvoke( + _Input(task="do something") + ) - assert len(captured) == 1 - messages = captured[0] + messages = out["messages"] assert len(messages) == 1 assert isinstance(messages[0], HumanMessage) assert messages[0].content == "do something" diff --git a/tests/agent/advanced/test_utils.py b/tests/agent/advanced/test_utils.py index c26865cd4..202db0cb4 100644 --- a/tests/agent/advanced/test_utils.py +++ b/tests/agent/advanced/test_utils.py @@ -71,6 +71,51 @@ async def test_resolve_input_attachments_downloads_and_adds_filepath( assert result["question"] == "summarize" +@pytest.mark.asyncio +async def test_resolve_input_attachments_uses_marked_runtime_workspace_factory( + tmp_path: Path, +) -> None: + """Runtime workspace factories resolve from RunnableConfig before download.""" + attachment_id = uuid.uuid4() + input_args = { + "book": { + "ID": str(attachment_id), + "FullName": "novel.txt", + "MimeType": "text/plain", + }, + } + + seen_config: dict[str, Any] = {} + + def backend_factory(runtime: Any) -> FilesystemBackend: + seen_config.update(runtime.config) + return FilesystemBackend( + root_dir=runtime.config["configurable"]["workspace"], + virtual_mode=True, + ) + + backend_factory.is_uipath_workspace_filesystem_backend = True # type: ignore[attr-defined] + + mock_client = MagicMock() + mock_client.attachments.download_async = AsyncMock() + with patch( + "uipath_langchain.agent.advanced.utils.UiPath", + return_value=mock_client, + ): + result = await resolve_input_attachments( + backend_factory, + ["$.book"], + input_args, + config={"configurable": {"workspace": str(tmp_path)}}, + ) + + assert seen_config == {"configurable": {"workspace": str(tmp_path)}} + expected_name = f"{attachment_id}_novel.txt" + call_kwargs = mock_client.attachments.download_async.call_args.kwargs + assert call_kwargs["destination_path"] == str(tmp_path / expected_name) + assert result["book"]["FilePath"] == f"/{expected_name}" + + @pytest.mark.asyncio async def test_resolve_input_attachments_skips_non_ticket_matches( tmp_path: Path, 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_backend.py b/tests/deepagents/test_backend.py new file mode 100644 index 000000000..30c63d6e5 --- /dev/null +++ b/tests/deepagents/test_backend.py @@ -0,0 +1,56 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import patch + +import pytest +from deepagents.backends.filesystem import FilesystemBackend +from langchain.tools import ToolRuntime + +from uipath_langchain.deepagents.backend import ( + WORKSPACE_FILESYSTEM_BACKEND_ATTR, + create_workspace_backend_factory, +) + + +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 = create_workspace_backend_factory(workspace_config_key="workspace") + + assert getattr(factory, WORKSPACE_FILESYSTEM_BACKEND_ATTR) is True + + backend = factory(_tool_runtime({"configurable": {"workspace": str(tmp_path)}})) + + assert isinstance(backend, FilesystemBackend) + assert backend.cwd == tmp_path.resolve() + assert backend.virtual_mode is True + + +def test_workspace_backend_factory_raises_when_config_missing() -> None: + factory = create_workspace_backend_factory(workspace_config_key="workspace") + + with pytest.raises(RuntimeError, match="workspace path missing"): + factory(_tool_runtime({"configurable": {}})) + + +def test_workspace_backend_factory_uses_active_config_for_current_runtime( + tmp_path, +) -> None: + factory = create_workspace_backend_factory(workspace_config_key="workspace") + + with patch( + "uipath_langchain.deepagents.backend.get_config", + return_value={"configurable": {"workspace": 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..cbbfa1b42 --- /dev/null +++ b/tests/deepagents/test_metadata.py @@ -0,0 +1,33 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +from deepagents import create_deep_agent +from langchain_core.language_models import BaseChatModel + +from uipath_langchain.deepagents.metadata import ( + is_deep_agent_graph, +) + + +def test_standard_deepagent_is_detected_from_native_metadata() -> None: + model = MagicMock(spec=BaseChatModel) + model.profile = None + + graph = create_deep_agent(model=model) + + assert is_deep_agent_graph(graph) + + +def test_deepagents_langsmith_metadata_is_detected() -> None: + graph = SimpleNamespace(config={"metadata": {"ls_integration": "deepagents"}}) + + assert is_deep_agent_graph(graph) + + +def test_deepagents_langsmith_metadata_is_detected_on_subgraph_node() -> None: + subgraph = SimpleNamespace(config={"metadata": {"ls_integration": "deepagents"}}) + graph = SimpleNamespace( + nodes={"advanced_agent": SimpleNamespace(runnable=subgraph)} + ) + + assert is_deep_agent_graph(graph) diff --git a/tests/deepagents/test_runtime_config.py b/tests/deepagents/test_runtime_config.py new file mode 100644 index 000000000..6374d5250 --- /dev/null +++ b/tests/deepagents/test_runtime_config.py @@ -0,0 +1,16 @@ +from unittest.mock import MagicMock + +from uipath_langchain.runtime.runtime import UiPathLangGraphRuntime + + +def test_runtime_merges_deep_agent_configurable_values() -> None: + runtime = UiPathLangGraphRuntime( + graph=MagicMock(), + runtime_id="runtime-1", + configurable={"uipath_workspace_path": "/tmp/workspace"}, + ) + + config = runtime._get_graph_config() + + assert config["configurable"]["thread_id"] == "runtime-1" + assert config["configurable"]["uipath_workspace_path"] == "/tmp/workspace" diff --git a/tests/deepagents/test_runtime_factory.py b/tests/deepagents/test_runtime_factory.py new file mode 100644 index 000000000..c748325d4 --- /dev/null +++ b/tests/deepagents/test_runtime_factory.py @@ -0,0 +1,77 @@ +from types import SimpleNamespace +from typing import Any, TypedDict +from unittest.mock import AsyncMock, MagicMock, patch + +from langgraph.graph import END, START, StateGraph +from uipath.runtime import HydrationPolicy, HydrationRuntime, UiPathRuntimeContext + +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_native_detection_survives_runtime_recompilation(tmp_path) -> None: + context = UiPathRuntimeContext(runtime_dir=str(tmp_path), state_file="state.db") + factory = UiPathLangGraphRuntimeFactory(context) + loaded = SimpleNamespace(config={"metadata": {"ls_integration": "deepagents"}}) + recompiled = _build_graph().compile() + + with ( + patch.object(factory, "_load_graph", AsyncMock(return_value=loaded)), + patch.object(factory, "_compile_graph", AsyncMock(return_value=recompiled)), + ): + result = await factory._resolve_and_compile_graph("agent", MagicMock()) + + assert result is recompiled + assert "agent" in factory._deep_agent_entrypoints + + +async def test_detected_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 = _build_graph().compile() + compiled.config = {"metadata": {"ls_integration": "deepagents"}} + + sdk = MagicMock() + 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=compiled, + runtime_id="runtime-1", + entrypoint="agent", + ) + + assert isinstance(runtime, HydrationRuntime) + assert runtime.policy == HydrationPolicy.SUSPEND_OR_SUCCESS + assert runtime.workspace.path.parent == tmp_path / "workspaces" + + resumable_runtime = runtime.delegate + langgraph_runtime = resumable_runtime.delegate + assert langgraph_runtime.configurable == { + "uipath_workspace_path": str(runtime.workspace.path), + } + + assert runtime.hydrator.workspace_path == runtime.workspace.path + assert runtime.hydrator.attachments is sdk.attachments + assert runtime.hydrator.jobs is sdk.jobs + 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"