diff --git a/samples/email-triage-agent/README.md b/samples/email-triage-agent/README.md new file mode 100644 index 000000000..977368fd0 --- /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, briefly waking 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 four required inputs at job start: + +```json +{ + "subject": "Issue", + "connection_name": "support@example.com", + "connection_folder": "Support", + "connector": "uipath-microsoft-outlook365" +} +``` + +| Field | Description | +|---|---| +| `subject` | Exact email subject to watch for. The IS trigger registers a server-side filter `(subject=='')` so only matching emails fire it. | +| `connection_name` | Name of the Outlook 365 connection in UiPath Integration Services. | +| `connection_folder` | Folder where the connection lives. | +| `connector` | Connector key in the IS catalog (typically `uipath-microsoft-outlook365`). | + +All four are persisted in state across loop iterations — set once at job start. + +## Connection requirements + +The Outlook connection passed in via input must be authorized to **read AND send** mail (`Mail.Read` + `Mail.Send` Graph scopes), and the `EMAIL_RECEIVED` IS trigger must be enabled on the connector. Re-authorize the connection from the UiPath Connections UI if either scope is missing. + +## Running locally + +```bash +uv sync +uipath run agent '{"subject": "Issue", "connection_name": "support@example.com", "connection_folder": "Support", "connector": "uipath-microsoft-outlook365"}' +``` + +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 'support@example.com' (folder='Support') 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 'support@example.com' (folder='Support') with subject='Issue' (triaged so far: 1)... +``` + +## Notes + +- **All four inputs are set once at job start.** Persisted in state across loop iterations — to change any of them (subject, connection, folder, connector), cancel the job and start a new one. +- **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. +- **Adding human-in-the-loop later.** The sample previously included a `route_to_human` step using `CreateTask` to escalate high-severity emails through Action Center. That branch was removed for shipping simplicity. Adding it back when you have a working Action Center app or wrapper process is straightforward — the LLM's structured output already supports a `requires_human` field if you need to re-introduce conditional escalation. 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/graph.py b/samples/email-triage-agent/graph.py new file mode 100644 index 000000000..8441e5ffa --- /dev/null +++ b/samples/email-triage-agent/graph.py @@ -0,0 +1,272 @@ +"""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" + + +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." + ) + connection_name: str = Field( + description="Name of the Outlook 365 connection in UiPath Integration Services." + ) + connection_folder: str = Field( + description="Folder where the Outlook connection lives." + ) + connector: str = Field( + description="Connector key in the IS catalog (typically 'uipath-microsoft-outlook365')." + ) + + +class GraphState(BaseModel): + subject: str = "" + connection_name: str = "" + connection_folder: str = "" + connector: 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: + cur: Any = email + for p in path: + if not isinstance(cur, dict): + return default + cur = cur.get(p) + return cur if isinstance(cur, str) else default + + +async def _send_outlook_reply( + message_id: str, + body: str, + connection_name: str, + connection_folder: str, + connector: 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() + connections = await sdk.connections.list_async( + name=connection_name, + folder_path=connection_folder, + connector_key=connector, + ) + connection = next( + (c for c in connections if c.name == connection_name), + None, + ) + if connection is None or connection.id is None: + raise RuntimeError( + f"Outlook connection {connection_name!r} not found in " + f"folder {connection_folder!r}." + ) + + 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]: + logger.info( + "Waiting for next email on '%s' (folder='%s') with subject=%r (triaged so far: %d)...", + state.connection_name, + state.connection_folder, + state.subject, + state.triage_count, + ) + email = interrupt( + WaitIntegrationEvent( + connector=state.connector, + connection_name=state.connection_name, + connection_folder_path=state.connection_folder, + 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, + connection_name=state.connection_name, + connection_folder=state.connection_folder, + connector=state.connector, + ) + 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": {} +}