Skip to content

Commit 9923ea7

Browse files
feat: add IS resume trigger sample
1 parent a3c7f53 commit 9923ea7

6 files changed

Lines changed: 403 additions & 0 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Email Triage Agent
2+
3+
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.
4+
5+
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.
6+
7+
## What this sample demonstrates
8+
9+
- **`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`.
10+
- An LLM call with strict structured output (Pydantic schema for the triage result).
11+
- A direct Microsoft Graph call to send the reply, authenticated with the OAuth token issued for the same UiPath connection that received the trigger.
12+
13+
## Flow
14+
15+
```
16+
START
17+
└─► wait_for_email (suspend on Outlook EMAIL_RECEIVED, resume with Graph Message)
18+
└─► triage_email (LLM → severity / category / summary / suggested_response)
19+
└─► send_reply (Graph POST /me/messages/{id}/reply with the LLM draft)
20+
└─► finalize (log result, clear transient state, increment counter)
21+
└─► wait_for_email (loop)
22+
```
23+
24+
## Input
25+
26+
The agent takes four required inputs at job start:
27+
28+
```json
29+
{
30+
"subject": "Issue",
31+
"connection_name": "support@example.com",
32+
"connection_folder": "Support",
33+
"connector": "uipath-microsoft-outlook365"
34+
}
35+
```
36+
37+
| Field | Description |
38+
|---|---|
39+
| `subject` | Exact email subject to watch for. The IS trigger registers a server-side filter `(subject=='<value>')` so only matching emails fire it. |
40+
| `connection_name` | Name of the Outlook 365 connection in UiPath Integration Services. |
41+
| `connection_folder` | Folder where the connection lives. |
42+
| `connector` | Connector key in the IS catalog (typically `uipath-microsoft-outlook365`). |
43+
44+
All four are persisted in state across loop iterations — set once at job start.
45+
46+
## Connection requirements
47+
48+
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.
49+
50+
## Running locally
51+
52+
```bash
53+
uv sync
54+
uipath run agent '{"subject": "Issue", "connection_name": "support@example.com", "connection_folder": "Support", "connector": "uipath-microsoft-outlook365"}'
55+
```
56+
57+
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.
58+
59+
Sample iteration log:
60+
61+
```
62+
[INFO] Waiting for next email on 'support@example.com' (folder='Support') with subject='Issue' (triaged so far: 0)...
63+
[INFO] Received email from alice@example.com: Issue
64+
[INFO] Triage: severity=P0_critical category=bug
65+
[INFO] Reply sent.
66+
[INFO] Triaged email #1 from alice@example.com (subject='Issue', severity=P0_critical, category=bug, reply_sent=True)
67+
[INFO] Waiting for next email on 'support@example.com' (folder='Support') with subject='Issue' (triaged so far: 1)...
68+
```
69+
70+
## Notes
71+
72+
- **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.
73+
- **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.
74+
- **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.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
%% AUTO-GENERATED by `uipath init`. Do not edit manually.
2+
%% Regenerated on every `uipath init`.
3+
flowchart TB
4+
__start__(__start__)
5+
wait_for_email(wait_for_email)
6+
triage_email(triage_email)
7+
send_reply(send_reply)
8+
finalize(finalize)
9+
__end__(__end__)
10+
__start__ --> wait_for_email
11+
finalize --> wait_for_email
12+
send_reply --> finalize
13+
triage_email --> send_reply
14+
wait_for_email --> triage_email
15+
triage_email --> __end__
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
"""Long-running support inbox triage agent.
2+
3+
Watches a UiPath Integration Services Outlook connection for emails whose
4+
subject matches the value passed in as agent input. Each match:
5+
6+
1. Resumes the suspended job with the enriched Microsoft Graph `Message`
7+
as the resume value of `WaitIntegrationEvent`.
8+
2. The LLM classifies the email into severity, category, a one-sentence
9+
summary, and a polite acknowledgement draft.
10+
3. The agent replies to the original email with the LLM-drafted
11+
acknowledgement (via Microsoft Graph, using the connection's OAuth token).
12+
4. The result is logged, transient state is cleared, and the agent loops
13+
back to suspend on the next matching email.
14+
15+
The graph has no terminal node — the agent stays SUSPENDED on the Outlook
16+
trigger forever, briefly waking to triage and reply to each matching email
17+
and then re-suspending. Cancel the job manually when you're done with it.
18+
19+
Demonstrates one suspend/resume primitive in a long-running agent:
20+
- `WaitIntegrationEvent` — suspend until an external IS connector event fires.
21+
"""
22+
23+
import logging
24+
from enum import Enum
25+
from typing import Any, Optional
26+
27+
import httpx
28+
from langchain_core.messages import HumanMessage, SystemMessage
29+
from langgraph.graph import START, StateGraph
30+
from langgraph.types import interrupt
31+
from pydantic import BaseModel, Field
32+
from uipath.platform import UiPath
33+
from uipath.platform.common import WaitIntegrationEvent
34+
from uipath_langchain.chat import UiPathChat
35+
36+
logger = logging.getLogger(__name__)
37+
38+
GRAPH_API_BASE = "https://graph.microsoft.com/v1.0"
39+
40+
41+
class Severity(str, Enum):
42+
P0_CRITICAL = "P0_critical"
43+
P1_HIGH = "P1_high"
44+
P2_NORMAL = "P2_normal"
45+
P3_LOW = "P3_low"
46+
47+
48+
class Category(str, Enum):
49+
BUG = "bug"
50+
FEATURE_REQUEST = "feature_request"
51+
HOWTO = "howto"
52+
BILLING = "billing"
53+
SPAM = "spam"
54+
OTHER = "other"
55+
56+
57+
class Triage(BaseModel):
58+
severity: Severity = Field(
59+
description=(
60+
"P0 = production outage / data loss, "
61+
"P1 = major workflow impact, "
62+
"P2 = normal request or single-user impact, "
63+
"P3 = low / cosmetic / general question."
64+
)
65+
)
66+
category: Category
67+
summary: str = Field(description="One-sentence summary in the customer's voice.")
68+
suggested_response: str = Field(
69+
description="Polite acknowledgement reply confirming receipt and next steps."
70+
)
71+
72+
73+
class GraphInput(BaseModel):
74+
subject: str = Field(
75+
description="The exact email subject to watch for. The IS trigger filters incoming emails by this value."
76+
)
77+
connection_name: str = Field(
78+
description="Name of the Outlook 365 connection in UiPath Integration Services."
79+
)
80+
connection_folder: str = Field(
81+
description="Folder where the Outlook connection lives."
82+
)
83+
connector: str = Field(
84+
description="Connector key in the IS catalog (typically 'uipath-microsoft-outlook365')."
85+
)
86+
87+
88+
class GraphState(BaseModel):
89+
subject: str = ""
90+
connection_name: str = ""
91+
connection_folder: str = ""
92+
connector: str = ""
93+
email: Optional[dict[str, Any]] = None
94+
triage: Optional[Triage] = None
95+
reply_sent: Optional[bool] = None
96+
reply_body: Optional[str] = None
97+
triage_count: int = 0
98+
99+
100+
llm = UiPathChat(model="gpt-4o-mini-2024-07-18")
101+
102+
103+
def _email_str(email: dict[str, Any], *path: str, default: str = "") -> str:
104+
cur: Any = email
105+
for p in path:
106+
if not isinstance(cur, dict):
107+
return default
108+
cur = cur.get(p)
109+
return cur if isinstance(cur, str) else default
110+
111+
112+
async def _send_outlook_reply(
113+
message_id: str,
114+
body: str,
115+
connection_name: str,
116+
connection_folder: str,
117+
connector: str,
118+
) -> None:
119+
"""Reply to an Outlook message via Microsoft Graph, using the OAuth token
120+
issued for the UiPath Outlook connection that received the trigger.
121+
"""
122+
sdk = UiPath()
123+
connections = await sdk.connections.list_async(
124+
name=connection_name,
125+
folder_path=connection_folder,
126+
connector_key=connector,
127+
)
128+
connection = next(
129+
(c for c in connections if c.name == connection_name),
130+
None,
131+
)
132+
if connection is None or connection.id is None:
133+
raise RuntimeError(
134+
f"Outlook connection {connection_name!r} not found in "
135+
f"folder {connection_folder!r}."
136+
)
137+
138+
token = await sdk.connections.retrieve_token_async(connection.id)
139+
140+
async with httpx.AsyncClient(timeout=30) as client:
141+
response = await client.post(
142+
f"{GRAPH_API_BASE}/me/messages/{message_id}/reply",
143+
headers={
144+
"Authorization": f"Bearer {token.access_token}",
145+
"Content-Type": "application/json",
146+
},
147+
json={"comment": body},
148+
)
149+
response.raise_for_status()
150+
151+
152+
async def wait_for_email(state: GraphState) -> dict[str, Any]:
153+
logger.info(
154+
"Waiting for next email on '%s' (folder='%s') with subject=%r (triaged so far: %d)...",
155+
state.connection_name,
156+
state.connection_folder,
157+
state.subject,
158+
state.triage_count,
159+
)
160+
email = interrupt(
161+
WaitIntegrationEvent(
162+
connector=state.connector,
163+
connection_name=state.connection_name,
164+
connection_folder_path=state.connection_folder,
165+
operation="EMAIL_RECEIVED",
166+
object_name="Message",
167+
filter_expression=f"(subject=='{state.subject}')",
168+
)
169+
)
170+
sender = _email_str(email, "from", "emailAddress", "address", default="?")
171+
logger.info("Received email from %s: %s", sender, _email_str(email, "subject"))
172+
return {"email": email}
173+
174+
175+
async def triage_email(state: GraphState) -> dict[str, Any]:
176+
email = state.email or {}
177+
sender = _email_str(email, "from", "emailAddress", "address", default="unknown")
178+
subject = _email_str(email, "subject")
179+
body = _email_str(email, "bodyPreview") or _email_str(email, "body", "content")
180+
181+
triage_llm = llm.with_structured_output(Triage)
182+
result: Triage = await triage_llm.ainvoke(
183+
[
184+
SystemMessage(
185+
"You are a support triage assistant. Read the customer email and "
186+
"produce a structured triage result.\n\n"
187+
"Severity guidelines:\n"
188+
"- P0: production outage, data loss, or anything blocking critical work.\n"
189+
"- P1: major workflow impact; affects many users.\n"
190+
"- P2: normal request or single-user impact.\n"
191+
"- P3: low priority, cosmetic, or general question.\n\n"
192+
"Always draft a polite acknowledgement confirming receipt and "
193+
"setting expectations for next steps."
194+
),
195+
HumanMessage(f"From: {sender}\nSubject: {subject}\n\n{body}"),
196+
]
197+
)
198+
logger.info(
199+
"Triage: severity=%s category=%s",
200+
result.severity.value,
201+
result.category.value,
202+
)
203+
return {"triage": result}
204+
205+
206+
async def send_reply(state: GraphState) -> dict[str, Any]:
207+
email = state.email or {}
208+
triage = state.triage
209+
message_id = email.get("id") if isinstance(email, dict) else None
210+
body = triage.suggested_response if triage else None
211+
212+
if not body:
213+
logger.warning("No reply body resolved — skipping send.")
214+
return {"reply_sent": False, "reply_body": None}
215+
216+
if not message_id:
217+
logger.warning("Email payload had no 'id' field — cannot send reply.")
218+
return {"reply_sent": False, "reply_body": body}
219+
220+
try:
221+
await _send_outlook_reply(
222+
message_id,
223+
body,
224+
connection_name=state.connection_name,
225+
connection_folder=state.connection_folder,
226+
connector=state.connector,
227+
)
228+
logger.info("Reply sent.")
229+
return {"reply_sent": True, "reply_body": body}
230+
except Exception:
231+
logger.exception("Failed to send Outlook reply.")
232+
return {"reply_sent": False, "reply_body": body}
233+
234+
235+
async def finalize(state: GraphState) -> dict[str, Any]:
236+
triage = state.triage
237+
assert triage is not None
238+
email = state.email or {}
239+
sender = _email_str(email, "from", "emailAddress", "address", default="unknown")
240+
subject = _email_str(email, "subject")
241+
242+
logger.info(
243+
"Triaged email #%d from %s (subject=%r, severity=%s, category=%s, reply_sent=%s)",
244+
state.triage_count + 1,
245+
sender,
246+
subject,
247+
triage.severity.value,
248+
triage.category.value,
249+
bool(state.reply_sent),
250+
)
251+
return {
252+
"triage_count": state.triage_count + 1,
253+
"email": None,
254+
"triage": None,
255+
"reply_sent": None,
256+
"reply_body": None,
257+
}
258+
259+
260+
builder = StateGraph(GraphState, input_schema=GraphInput)
261+
builder.add_node("wait_for_email", wait_for_email)
262+
builder.add_node("triage_email", triage_email)
263+
builder.add_node("send_reply", send_reply)
264+
builder.add_node("finalize", finalize)
265+
266+
builder.add_edge(START, "wait_for_email")
267+
builder.add_edge("wait_for_email", "triage_email")
268+
builder.add_edge("triage_email", "send_reply")
269+
builder.add_edge("send_reply", "finalize")
270+
builder.add_edge("finalize", "wait_for_email")
271+
272+
graph = builder.compile()
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"graphs": {
3+
"agent": "./graph.py:graph"
4+
}
5+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[project]
2+
name = "email-triage-agent"
3+
version = "0.0.1"
4+
description = "Wait for new emails via UiPath Integration Services, triage them with an LLM, and optionally escalate to a human via Action Center."
5+
authors = [{ name = "John Doe", email = "john.doe@myemail.com" }]
6+
requires-python = ">=3.11"
7+
dependencies = [
8+
"httpx>=0.27",
9+
"langgraph>=1.0.4",
10+
"uipath-langchain"
11+
]
12+
13+
[dependency-groups]
14+
dev = [
15+
"uipath-dev",
16+
]
17+
18+
[[tool.uv.index]]
19+
name = "testpypi"
20+
url = "https://test.pypi.org/simple/"
21+
publish-url = "https://test.pypi.org/legacy/"
22+
explicit = true

0 commit comments

Comments
 (0)