From f794ff0987d296cd97d340b00b6541978072e892 Mon Sep 17 00:00:00 2001 From: GabrielVasilescu04 Date: Tue, 28 Apr 2026 13:50:37 +0300 Subject: [PATCH] feat: add IS resume trigger sample --- samples/email-triage-agent/README.md | 74 +++++++ samples/email-triage-agent/agent.mermaid | 15 ++ samples/email-triage-agent/bindings.json | 21 ++ samples/email-triage-agent/graph.py | 251 ++++++++++++++++++++++ samples/email-triage-agent/langgraph.json | 5 + samples/email-triage-agent/pyproject.toml | 22 ++ samples/email-triage-agent/uipath.json | 15 ++ 7 files changed, 403 insertions(+) create mode 100644 samples/email-triage-agent/README.md create mode 100644 samples/email-triage-agent/agent.mermaid create mode 100644 samples/email-triage-agent/bindings.json create mode 100644 samples/email-triage-agent/graph.py create mode 100644 samples/email-triage-agent/langgraph.json create mode 100644 samples/email-triage-agent/pyproject.toml create mode 100644 samples/email-triage-agent/uipath.json diff --git a/samples/email-triage-agent/README.md b/samples/email-triage-agent/README.md new file mode 100644 index 000000000..2e802fee0 --- /dev/null +++ b/samples/email-triage-agent/README.md @@ -0,0 +1,74 @@ +# Email Triage Agent + +A long-running LangGraph agent that watches a UiPath Integration Services Outlook connection for new emails matching a subject filter, classifies each one with an LLM, and replies to the original email with a polite acknowledgement drafted by the LLM. + +The graph has no terminal node — once started, the agent stays SUSPENDED on the Outlook trigger forever, resuming to triage and reply to each matching email and then re-suspending. Cancel the job manually when you're done. + +## What this sample demonstrates + +- **`WaitIntegrationEvent`** — the agent suspends until an external IS connector event fires. The Connections-service registers a remote subscription on the user's behalf; when a matching email arrives, Orchestrator resumes the job and the SDK enriches the IS event metadata into the actual Microsoft Graph `Message`. +- An LLM call with strict structured output (Pydantic schema for the triage result). +- A direct Microsoft Graph call to send the reply, authenticated with the OAuth token issued for the same UiPath connection that received the trigger. + +## Flow + +``` +START + └─► wait_for_email (suspend on Outlook EMAIL_RECEIVED, resume with Graph Message) + └─► triage_email (LLM → severity / category / summary / suggested_response) + └─► send_reply (Graph POST /me/messages/{id}/reply with the LLM draft) + └─► finalize (log result, clear transient state, increment counter) + └─► wait_for_email (loop) +``` + +## Input + +The agent takes a single required input at job start: + +```json +{ "subject": "Issue" } +``` + +| Field | Description | +|---|---| +| `subject` | Exact email subject to watch for. The IS trigger registers a server-side filter `(subject=='')` so only matching emails fire it. | + +`subject` is persisted in state across loop iterations — set once at job start. + +## Connection (binding) + +The Outlook connection used by the agent is declared as a **binding** in `bindings.json` rather than passed in as input. The code references a placeholder connection key (``) and calls `sdk.connections.retrieve_async(OUTLOOK_CONNECTION_KEY)`. That method is decorated with `@resource_override("connection", resource_identifier="key")`, which inspects the runtime's binding-overwrite context and substitutes the deployer-selected connection's real key before the HTTP call. The agent then reads `connection.name` and `connection.folder.path` from the resolved connection and feeds them into `WaitIntegrationEvent`. + +To run the agent: + +- **In Orchestrator (deployed)**: pick the actual Outlook 365 connection when configuring the agent — Orchestrator's binding UI presents the deployer with the list of available `uipath-microsoft-outlook365` connections and overwrites the placeholder key. +- **Locally**: edit the `OUTLOOK_CONNECTION_KEY` constant at the top of `graph.py` to a real connection key in your tenant, or pass a resource-overwrites file via `--resource-overwrites`. + +The connector key (`uipath-microsoft-outlook365`) is hardcoded. + +The connection must be authorized to **read AND send** mail (`Mail.Read` + `Mail.Send` Graph scopes). Re-authorize the connection from the UiPath Connections UI if either scope is missing. + +## Running locally + +```bash +uv sync +uipath run agent '{"subject": "Issue"}' +``` + +The agent suspends waiting for the first matching email. Send (or have someone send) a message with subject `Issue` to the inbox the connection is bound to. When it arrives, the agent resumes, triages, replies, logs the result, and re-suspends on the next email. + +Sample iteration log: + +``` +[INFO] Waiting for next email on '' (folder='') with subject='Issue' (triaged so far: 0)... +[INFO] Received email from alice@example.com: Issue +[INFO] Triage: severity=P0_critical category=bug +[INFO] Reply sent. +[INFO] Triaged email #1 from alice@example.com (subject='Issue', severity=P0_critical, category=bug, reply_sent=True) +[INFO] Waiting for next email on '' (folder='') with subject='Issue' (triaged so far: 1)... +``` + +## Notes + +- **`subject` is set once at job start.** Persisted in state across loop iterations — to change it, cancel the job and start a new one. The connection is bound at deploy time via `bindings.json`, not via input. +- **Long-running pattern.** This sample is deliberately a single long-lived job to demo `WaitIntegrationEvent` cleanly. The idiomatic UiPath production pattern for "react to many emails" is the inverse: configure an Orchestrator event trigger that starts a fresh, one-shot agent job per matching email. That gives you a finite lifecycle per email, parallel processing, and no recursion-limit concerns. Use whichever shape fits your operational model. diff --git a/samples/email-triage-agent/agent.mermaid b/samples/email-triage-agent/agent.mermaid new file mode 100644 index 000000000..bed343499 --- /dev/null +++ b/samples/email-triage-agent/agent.mermaid @@ -0,0 +1,15 @@ +%% AUTO-GENERATED by `uipath init`. Do not edit manually. +%% Regenerated on every `uipath init`. +flowchart TB + __start__(__start__) + wait_for_email(wait_for_email) + triage_email(triage_email) + send_reply(send_reply) + finalize(finalize) + __end__(__end__) + __start__ --> wait_for_email + finalize --> wait_for_email + send_reply --> finalize + triage_email --> send_reply + wait_for_email --> triage_email + triage_email --> __end__ diff --git a/samples/email-triage-agent/bindings.json b/samples/email-triage-agent/bindings.json new file mode 100644 index 000000000..2addaea83 --- /dev/null +++ b/samples/email-triage-agent/bindings.json @@ -0,0 +1,21 @@ +{ + "version": "2.0", + "resources": [ + { + "resource": "connection", + "key": "", + "value": { + "ConnectionId": { + "defaultValue": "", + "isExpression": false, + "displayName": "Outlook Connection" + } + }, + "metadata": { + "Connector": "uipath-microsoft-outlook365", + "useConnectionService": "true", + "BindingsVersion": "2.2" + } + } + ] +} diff --git a/samples/email-triage-agent/graph.py b/samples/email-triage-agent/graph.py new file mode 100644 index 000000000..4f5fcf135 --- /dev/null +++ b/samples/email-triage-agent/graph.py @@ -0,0 +1,251 @@ +"""Long-running support inbox triage agent. + +Watches a UiPath Integration Services Outlook connection for emails whose +subject matches the value passed in as agent input. Each match: + +1. Resumes the suspended job with the enriched Microsoft Graph `Message` + as the resume value of `WaitIntegrationEvent`. +2. The LLM classifies the email into severity, category, a one-sentence + summary, and a polite acknowledgement draft. +3. The agent replies to the original email with the LLM-drafted + acknowledgement (via Microsoft Graph, using the connection's OAuth token). +4. The result is logged, transient state is cleared, and the agent loops + back to suspend on the next matching email. + +The graph has no terminal node — the agent stays SUSPENDED on the Outlook +trigger forever, briefly waking to triage and reply to each matching email +and then re-suspending. Cancel the job manually when you're done with it. + +Demonstrates one suspend/resume primitive in a long-running agent: +- `WaitIntegrationEvent` — suspend until an external IS connector event fires. +""" + +import logging +from enum import Enum +from typing import Any, Optional + +import httpx +from langchain_core.messages import HumanMessage, SystemMessage +from langgraph.graph import START, StateGraph +from langgraph.types import interrupt +from pydantic import BaseModel, Field +from uipath.platform import UiPath +from uipath.platform.common import WaitIntegrationEvent +from uipath_langchain.chat import UiPathChat + +logger = logging.getLogger(__name__) + +GRAPH_API_BASE = "https://graph.microsoft.com/v1.0" +OUTLOOK_CONNECTOR = "uipath-microsoft-outlook365" + +# Placeholder connection key bound to a real connection via bindings.json. +# `connections.retrieve_async` is decorated with @resource_override("connection", +# resource_identifier="key"), so at run time the decorator inspects the binding +# overwrite context and substitutes the deployer-selected connection's real key. +OUTLOOK_CONNECTION_KEY = "" + + +class Severity(str, Enum): + P0_CRITICAL = "P0_critical" + P1_HIGH = "P1_high" + P2_NORMAL = "P2_normal" + P3_LOW = "P3_low" + + +class Category(str, Enum): + BUG = "bug" + FEATURE_REQUEST = "feature_request" + HOWTO = "howto" + BILLING = "billing" + SPAM = "spam" + OTHER = "other" + + +class Triage(BaseModel): + severity: Severity = Field( + description=( + "P0 = production outage / data loss, " + "P1 = major workflow impact, " + "P2 = normal request or single-user impact, " + "P3 = low / cosmetic / general question." + ) + ) + category: Category + summary: str = Field(description="One-sentence summary in the customer's voice.") + suggested_response: str = Field( + description="Polite acknowledgement reply confirming receipt and next steps." + ) + + +class GraphInput(BaseModel): + subject: str = Field( + description="The exact email subject to watch for. The IS trigger filters incoming emails by this value." + ) + + +class GraphState(BaseModel): + subject: str = "" + email: Optional[dict[str, Any]] = None + triage: Optional[Triage] = None + reply_sent: Optional[bool] = None + reply_body: Optional[str] = None + triage_count: int = 0 + + +llm = UiPathChat(model="gpt-4o-mini-2024-07-18") + + +def _email_str(email: dict[str, Any], *path: str, default: str = "") -> str: + current: Any = email + for p in path: + if not isinstance(current, dict): + return default + current = current.get(p) + return current if isinstance(current, str) else default + + +async def _send_outlook_reply(message_id: str, body: str) -> None: + """Reply to an Outlook message via Microsoft Graph, using the OAuth token + issued for the UiPath Outlook connection that received the trigger. + """ + sdk = UiPath() + connection = await sdk.connections.retrieve_async(OUTLOOK_CONNECTION_KEY) + if connection.id is None: + raise RuntimeError( + f"Outlook connection {OUTLOOK_CONNECTION_KEY!r} could not be resolved." + ) + + token = await sdk.connections.retrieve_token_async(connection.id) + + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post( + f"{GRAPH_API_BASE}/me/messages/{message_id}/reply", + headers={ + "Authorization": f"Bearer {token.access_token}", + "Content-Type": "application/json", + }, + json={"comment": body}, + ) + response.raise_for_status() + + +async def wait_for_email(state: GraphState) -> dict[str, Any]: + sdk = UiPath() + connection = await sdk.connections.retrieve_async(OUTLOOK_CONNECTION_KEY) + folder_path = ( + connection.folder.get("path") if isinstance(connection.folder, dict) else None + ) + logger.info( + "Waiting for next email on '%s' (folder='%s') with subject=%r (triaged so far: %d)...", + connection.name, + folder_path, + state.subject, + state.triage_count, + ) + email = interrupt( + WaitIntegrationEvent( + connector=OUTLOOK_CONNECTOR, + connection_name=connection.name or "", + connection_folder_path=folder_path, + operation="EMAIL_RECEIVED", + object_name="Message", + filter_expression=f"(subject=='{state.subject}')", + ) + ) + sender = _email_str(email, "from", "emailAddress", "address", default="?") + logger.info("Received email from %s: %s", sender, _email_str(email, "subject")) + return {"email": email} + + +async def triage_email(state: GraphState) -> dict[str, Any]: + email = state.email or {} + sender = _email_str(email, "from", "emailAddress", "address", default="unknown") + subject = _email_str(email, "subject") + body = _email_str(email, "bodyPreview") or _email_str(email, "body", "content") + + triage_llm = llm.with_structured_output(Triage) + result: Triage = await triage_llm.ainvoke( + [ + SystemMessage( + "You are a support triage assistant. Read the customer email and " + "produce a structured triage result.\n\n" + "Severity guidelines:\n" + "- P0: production outage, data loss, or anything blocking critical work.\n" + "- P1: major workflow impact; affects many users.\n" + "- P2: normal request or single-user impact.\n" + "- P3: low priority, cosmetic, or general question.\n\n" + "Always draft a polite acknowledgement confirming receipt and " + "setting expectations for next steps." + ), + HumanMessage(f"From: {sender}\nSubject: {subject}\n\n{body}"), + ] + ) + logger.info( + "Triage: severity=%s category=%s", + result.severity.value, + result.category.value, + ) + return {"triage": result} + + +async def send_reply(state: GraphState) -> dict[str, Any]: + email = state.email or {} + triage = state.triage + message_id = email.get("id") if isinstance(email, dict) else None + body = triage.suggested_response if triage else None + + if not body: + logger.warning("No reply body resolved — skipping send.") + return {"reply_sent": False, "reply_body": None} + + if not message_id: + logger.warning("Email payload had no 'id' field — cannot send reply.") + return {"reply_sent": False, "reply_body": body} + + try: + await _send_outlook_reply(message_id, body) + logger.info("Reply sent.") + return {"reply_sent": True, "reply_body": body} + except Exception: + logger.exception("Failed to send Outlook reply.") + return {"reply_sent": False, "reply_body": body} + + +async def finalize(state: GraphState) -> dict[str, Any]: + triage = state.triage + assert triage is not None + email = state.email or {} + sender = _email_str(email, "from", "emailAddress", "address", default="unknown") + subject = _email_str(email, "subject") + + logger.info( + "Triaged email #%d from %s (subject=%r, severity=%s, category=%s, reply_sent=%s)", + state.triage_count + 1, + sender, + subject, + triage.severity.value, + triage.category.value, + bool(state.reply_sent), + ) + return { + "triage_count": state.triage_count + 1, + "email": None, + "triage": None, + "reply_sent": None, + "reply_body": None, + } + + +builder = StateGraph(GraphState, input_schema=GraphInput) +builder.add_node("wait_for_email", wait_for_email) +builder.add_node("triage_email", triage_email) +builder.add_node("send_reply", send_reply) +builder.add_node("finalize", finalize) + +builder.add_edge(START, "wait_for_email") +builder.add_edge("wait_for_email", "triage_email") +builder.add_edge("triage_email", "send_reply") +builder.add_edge("send_reply", "finalize") +builder.add_edge("finalize", "wait_for_email") + +graph = builder.compile() diff --git a/samples/email-triage-agent/langgraph.json b/samples/email-triage-agent/langgraph.json new file mode 100644 index 000000000..da3701454 --- /dev/null +++ b/samples/email-triage-agent/langgraph.json @@ -0,0 +1,5 @@ +{ + "graphs": { + "agent": "./graph.py:graph" + } +} diff --git a/samples/email-triage-agent/pyproject.toml b/samples/email-triage-agent/pyproject.toml new file mode 100644 index 000000000..9221a3a2e --- /dev/null +++ b/samples/email-triage-agent/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "email-triage-agent" +version = "0.0.1" +description = "Wait for new emails via UiPath Integration Services, triage them with an LLM, and optionally escalate to a human via Action Center." +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "httpx>=0.27", + "langgraph>=1.0.4", + "uipath-langchain" +] + +[dependency-groups] +dev = [ + "uipath-dev", +] + +[[tool.uv.index]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +publish-url = "https://test.pypi.org/legacy/" +explicit = true diff --git a/samples/email-triage-agent/uipath.json b/samples/email-triage-agent/uipath.json new file mode 100644 index 000000000..bd7403fd2 --- /dev/null +++ b/samples/email-triage-agent/uipath.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://cloud.uipath.com/draft/2024-12/uipath", + "runtimeOptions": { + "isConversational": false + }, + "packOptions": { + "fileExtensionsIncluded": [], + "filesIncluded": [], + "filesExcluded": [], + "directoriesExcluded": [], + "includeUvLock": true + }, + "functions": {}, + "agents": {} +}