Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guardrails.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ agent = create_agent(

### Escalation action (human-in-the-loop)

`EscalateAction` routes a violation to a **human reviewer** instead of logging or blocking it. On a violation it builds the review payload and calls the documented HITL primitive [`interrupt(CreateEscalation(...))`](https://uipath.github.io/uipath-python/langchain/human_in_the_loop/#3-createescalation) — creating a task in a UiPath **Action App** and **suspending the run** until the reviewer responds. On resume:
`EscalateAction` routes a violation to a **human reviewer** instead of logging or blocking it. On a violation it builds the review payload and calls the documented HITL primitive [`interrupt(CreateEscalation(...))`](https://uipath.github.io/uipath-python/langchain/human_in_the_loop/#createescalation) — creating a task in a UiPath **Action App** and **suspending the run** until the reviewer responds. On resume:

- **Approve** — if the reviewer edited the content, the edited value is substituted back into the flagged message / tool args / output; otherwise the original is kept. The edit is read from `ReviewedInputs` for a PRE (input) escalation and `ReviewedOutputs` for a POST (output) one.
- **Reject** — raises `GuardrailBlockException`, terminating the run.
Expand Down
468 changes: 370 additions & 98 deletions docs/human_in_the_loop.md

Large diffs are not rendered by default.

27 changes: 14 additions & 13 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
[project]
name = "uipath-langchain"
version = "0.12.3"
version = "0.13.19"
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
dependencies = [
"uipath>=2.10.79, <2.12.0",
"uipath>=2.11.14, <2.13.0",
Comment thread
UIPath-Harshit marked this conversation as resolved.
"uipath-core>=0.5.20, <0.6.0",
"uipath-platform>=0.1.71, <0.2.0",
"uipath-runtime>=0.11.0, <0.12.0",
"uipath-platform>=0.1.86, <0.2.0",
"uipath-runtime>=0.11.4, <0.12.0",
"langgraph>=1.1.8, <2.0.0",
"langchain-core>=1.2.11, <2.0.0",
"langchain-core>=1.2.27, <2.0.0",
"langgraph-checkpoint-sqlite>=3.0.3, <4.0.0",
"langchain>=1.0.0, <2.0.0",
"langchain>=1.2.15, <2.0.0",
"deepagents>=0.5.9, <0.6.0",
"pydantic-settings>=2.6.0",
"python-dotenv>=1.0.1",
"httpx>=0.27.0",
Expand All @@ -23,7 +24,7 @@ dependencies = [
"langchain-mcp-adapters==0.2.1",
"pillow>=12.1.1",
"a2a-sdk>=0.2.0,<1.0.0",
"uipath-langchain-client[openai]>=1.14.0,<1.15.0",
"uipath-langchain-client[openai]>=1.14.1,<1.15.0",
]

classifiers = [
Expand All @@ -40,21 +41,21 @@ maintainers = [

[project.optional-dependencies]
anthropic = [
"uipath-langchain-client[anthropic]>=1.14.0,<1.15.0",
"uipath-langchain-client[anthropic]>=1.14.1,<1.15.0",
]
vertex = [
"uipath-langchain-client[google]>=1.14.0,<1.15.0",
"uipath-langchain-client[vertexai]>=1.14.0,<1.15.0",
"uipath-langchain-client[google]>=1.14.1,<1.15.0",
"uipath-langchain-client[vertexai]>=1.14.1,<1.15.0",
]
bedrock = [
"uipath-langchain-client[bedrock]>=1.14.0,<1.15.0",
"uipath-langchain-client[bedrock]>=1.14.1,<1.15.0",
"boto3-stubs>=1.41.4",
]
fireworks = [
"uipath-langchain-client[fireworks]>=1.14.0,<1.15.0",
"uipath-langchain-client[fireworks]>=1.14.1,<1.15.0",
]
all = [
"uipath-langchain-client[all]>=1.14.0,<1.15.0",
"uipath-langchain-client[all]>=1.14.1,<1.15.0",
]

[project.entry-points."uipath.middlewares"]
Expand Down
2 changes: 1 addition & 1 deletion samples/chat-uipath-agent/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,5 @@
DO NOT do any math calculations unless specifically related to movie statistics or box office figures.
"""

llm = UiPathChatOpenAI(model="gpt-4o-mini-2024-07-18")
llm = UiPathChatOpenAI(model="gpt-4.1-mini-2025-04-14")
graph = create_agent(llm, tools=[search_tool], system_prompt=movie_system_prompt)
2 changes: 1 addition & 1 deletion samples/email-triage-agent/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ class GraphState(BaseModel):
triage_count: int = 0


llm = UiPathChat(model="gpt-4o-mini-2024-07-18")
llm = UiPathChat(model="gpt-4.1-mini-2025-04-14")


def _email_str(email: dict[str, Any], *path: str, default: str = "") -> str:
Expand Down
2 changes: 1 addition & 1 deletion samples/ticket-classification/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def decide_next_node(state: GraphState) -> Literal["classify", "notify_team"]:

async def classify(state: GraphState) -> Command:
"""Classify the support ticket using LLM."""
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
llm = ChatOpenAI(model="gpt-4.1-mini-2025-04-14", temperature=0)

if state.get("last_predicted_category", None):
predicted_category = state["last_predicted_category"]
Expand Down
29 changes: 29 additions & 0 deletions src/uipath_langchain/agent/advanced/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""UiPath Advanced agent implementation."""

from deepagents import CompiledSubAgent, SubAgent
from deepagents.backends import BackendProtocol, FilesystemBackend
from deepagents.backends.protocol import BackendFactory

from .agent import create_advanced_agent, create_advanced_agent_graph
from .types import AdvancedAgentGraphState
from .utils import (
MEMORY_DIR_NAME,
MEMORY_INDEX_FILENAME,
MEMORY_INDEX_VIRTUAL_PATH,
create_state_with_input,
)

__all__ = [
"MEMORY_DIR_NAME",
"MEMORY_INDEX_FILENAME",
"MEMORY_INDEX_VIRTUAL_PATH",
"AdvancedAgentGraphState",
"BackendFactory",
"BackendProtocol",
"CompiledSubAgent",
"FilesystemBackend",
"SubAgent",
"create_advanced_agent",
"create_advanced_agent_graph",
"create_state_with_input",
]
122 changes: 122 additions & 0 deletions src/uipath_langchain/agent/advanced/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Advanced agent builder."""

from collections.abc import Callable, Sequence
from typing import Any

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.tools import BaseTool
from langgraph.graph import END, START
from langgraph.graph.state import CompiledStateGraph, StateGraph
from pydantic import BaseModel

from uipath_langchain.agent.react.job_attachments import get_job_attachment_paths

from .types import AdvancedAgentGraphState
from .utils import (
MEMORY_INDEX_VIRTUAL_PATH,
create_state_with_input,
resolve_input_attachments,
)


def create_advanced_agent(
model: BaseChatModel,
system_prompt: str = "",
tools: Sequence[BaseTool] = (),
subagents: Sequence[SubAgent | CompiledSubAgent] = (),
backend: BackendProtocol | BackendFactory | None = None,
response_format: ResponseFormat[Any] | None = None,
memory: Sequence[str] = (),
) -> CompiledStateGraph[Any, Any, Any, Any]:
"""Create a deepagents agent with planning, filesystem, and sub-agent tools.

``memory`` is a list of file paths loaded via deepagents' ``MemoryMiddleware``:
each is read from ``backend`` and injected into the system prompt every turn,
and the model maintains them with ``edit_file``. Empty disables the middleware.
"""
return _create_deep_agent(
model=model,
system_prompt=system_prompt,
tools=list(tools),
subagents=list(subagents),
backend=backend,
response_format=response_format,
memory=list(memory) or None,
)


def create_advanced_agent_graph(
model: BaseChatModel,
tools: Sequence[BaseTool],
system_prompt: str,
backend: BackendProtocol | BackendFactory | None,
response_format: ResponseFormat[Any] | None,
input_schema: type[BaseModel] | None,
output_schema: type[BaseModel],
build_user_message: Callable[[dict[str, Any]], str],
) -> 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'
``MemoryMiddleware`` reads ``/memory/MEMORY.md`` from the backend each turn.
Memory stays disabled for non-filesystem backends, which carry no workspace.
"""
memory_sources = (
[MEMORY_INDEX_VIRTUAL_PATH] if isinstance(backend, FilesystemBackend) else []
)

inner_graph = create_advanced_agent(
model=model,
tools=tools,
system_prompt=system_prompt,
backend=backend,
response_format=response_format,
memory=memory_sources,
)

wrapper_state = create_state_with_input(input_schema)
internal_fields = set(AdvancedAgentGraphState.model_fields.keys())
attachment_paths = (
get_job_attachment_paths(input_schema) if input_schema is not None else []
)

async def transform_input_async(state: BaseModel) -> 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 = (
input_schema.model_validate(input_data).model_dump(by_alias=True)
if input_schema is not None
else {}
)
if attachment_paths:
input_args = await resolve_input_attachments(
backend, attachment_paths, input_args
)
user_text = build_user_message(input_args)
return {"messages": [HumanMessage(content=user_text, id="user-input")]}

def transform_output(state: BaseModel) -> dict[str, Any]:
structured = getattr(state, "structured_response", {})
return output_schema.model_validate(structured).model_dump()

wrapper: StateGraph[Any, Any, Any, Any] = StateGraph(
wrapper_state, input_schema=input_schema, output_schema=output_schema
)
wrapper.add_node("transform_input", transform_input_async)
wrapper.add_node("advanced_agent", inner_graph)
wrapper.add_node("transform_output", transform_output)
wrapper.add_edge(START, "transform_input")
wrapper.add_edge("transform_input", "advanced_agent")
wrapper.add_edge("advanced_agent", "transform_output")
wrapper.add_edge("transform_output", END)

return wrapper
14 changes: 14 additions & 0 deletions src/uipath_langchain/agent/advanced/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""State types for the advanced agent wrapper graph."""

from typing import Annotated, Any

from langchain_core.messages import AnyMessage
from langgraph.graph.message import add_messages
from pydantic import BaseModel


class AdvancedAgentGraphState(BaseModel):
"""Graph state for the advanced agent wrapper."""

messages: Annotated[list[AnyMessage], add_messages] = []
structured_response: dict[str, Any] = {}
110 changes: 110 additions & 0 deletions src/uipath_langchain/agent/advanced/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Advanced agent utilities."""

import asyncio
import copy
import logging
import uuid
from pathlib import Path
from typing import Any, NamedTuple, cast

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 pydantic import BaseModel
from uipath.platform import UiPath
from uipath.platform.attachments import Attachment

from .types import AdvancedAgentGraphState

logger = logging.getLogger(__name__)

# --- Workspace memory layout ---
# Durable memory lives under <workspace>/memory/: MEMORY.md is the always-loaded
# index, entries live in <workspace>/memory/<name>.md. deepagents' MemoryMiddleware
# handles loading/injection, but backed by the agent's FilesystemBackend (run-scoped,
# persisted via WorkspaceHydrator) rather than the cross-run StoreBackend.
MEMORY_DIR_NAME = "memory"
MEMORY_INDEX_FILENAME = "MEMORY.md"

# 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 create_state_with_input(
input_schema: type[BaseModel] | None,
) -> type[AdvancedAgentGraphState]:
"""Create combined state by merging AdvancedAgentGraphState with the input schema."""
if input_schema is None:
return AdvancedAgentGraphState
CompleteState = type(
"CompleteAdvancedAgentGraphState",
(AdvancedAgentGraphState, input_schema),
{},
)
cast(type[BaseModel], CompleteState).model_rebuild()
return CompleteState


class _AttachmentDownload(NamedTuple):
"""One input attachment to download and patch back into the args."""

location: Any
attachment_id: uuid.UUID
file_name: str
ticket: dict[str, Any]


async def resolve_input_attachments(
backend: BackendProtocol | BackendFactory | None,
attachment_paths: list[str],
input_args: dict[str, Any],
) -> dict[str, Any]:
"""Download attachment-shaped inputs into the backend and add a ``FilePath``.

Each ticket is streamed to ``<backend.cwd>/<ID>_<name>`` and augmented with a
``FilePath`` so the agent's file tools can open it. FilesystemBackend only.
"""
if not isinstance(backend, FilesystemBackend):
raise NotImplementedError(
"Advanced agent with input attachments requires a FilesystemBackend, "
f"got {type(backend).__name__}"
)

result = copy.deepcopy(input_args)
client = UiPath()

worklist: list[_AttachmentDownload] = []
for path_expr in attachment_paths:
for match in jsonpath_parse(path_expr).find(result):
ticket = match.value
if not isinstance(ticket, dict) or "ID" not in ticket:
continue
att = Attachment.model_validate(ticket, from_attributes=True)
worklist.append(
_AttachmentDownload(
location=match.full_path,
attachment_id=att.id,
# basename only: full_name is caller-controlled, keep the
# download inside the workspace (no path traversal)
file_name=f"{att.id}_{Path(att.full_name).name}",
ticket=ticket,
)
)

logger.info(
"Downloading %d input attachment(s) into %s", len(worklist), backend.cwd
)

await asyncio.gather(
*(
client.attachments.download_async(
key=item.attachment_id,
destination_path=str(backend.cwd / item.file_name),
)
for item in worklist
)
)
for item in worklist:
item.location.update(result, {**item.ticket, "FilePath": f"/{item.file_name}"})
return result
Loading