|
| 1 | +# Tool-Calling Suspend/Resume Sample |
| 2 | + |
| 3 | +This sample demonstrates how agents can suspend execution at specific points, then resume seamlessly when external work completes. This is useful for long-running automations where you don't want to block the agent execution. |
| 4 | + |
| 5 | +## Quick Start |
| 6 | + |
| 7 | +Test the suspend/resume flow using the evaluation command: |
| 8 | + |
| 9 | +```bash |
| 10 | +cd samples/tool-calling-suspend-resume |
| 11 | + |
| 12 | +# Step 1: Run to suspend |
| 13 | +uv run uipath eval agent-simple evaluations/eval-sets/test_simple_no_auth.json |
| 14 | + |
| 15 | +# Step 2: Resume with input override |
| 16 | +uv run uipath eval agent-simple evaluations/eval-sets/test_simple_no_auth.json --resume --input-overrides '{"query": "Test suspend with simple payload"}' |
| 17 | +``` |
| 18 | + |
| 19 | +## What is Suspend/Resume? |
| 20 | + |
| 21 | +The suspend/resume pattern allows agents to: |
| 22 | +- **Suspend** execution at specific points (e.g., when waiting for external work) |
| 23 | +- **Persist** their state to disk |
| 24 | +- **Resume** execution later when the work completes |
| 25 | +- **Continue** seamlessly from where they left off |
| 26 | + |
| 27 | +This is critical for: |
| 28 | +- Long-running RPA automations |
| 29 | +- Human-in-the-loop workflows |
| 30 | +- External API calls with async callbacks |
| 31 | +- Multi-step processes across systems |
| 32 | + |
| 33 | +## How It Works |
| 34 | + |
| 35 | +### The `interrupt()` Function |
| 36 | + |
| 37 | +The key is LangGraph's `interrupt()` function: |
| 38 | + |
| 39 | +```python |
| 40 | +async def suspend_node(state: State) -> State: |
| 41 | + logger.info("About to suspend execution...") |
| 42 | + |
| 43 | + # 🔴 Execution SUSPENDS here! |
| 44 | + # State is saved to SQLite checkpoint |
| 45 | + resume_data = interrupt({ |
| 46 | + "message": "Waiting for external completion", |
| 47 | + "query": state.query |
| 48 | + }) |
| 49 | + |
| 50 | + # 🟢 This code runs AFTER resume |
| 51 | + logger.info(f"Received resume data: {resume_data}") |
| 52 | + result = f"Completed with resume data: {resume_data}" |
| 53 | + return {"query": state.query, "result": result} |
| 54 | +``` |
| 55 | + |
| 56 | +### The Lifecycle |
| 57 | + |
| 58 | +``` |
| 59 | +┌─────────────────────────────────────────────────────────────────┐ |
| 60 | +│ 1. SUSPEND PHASE │ |
| 61 | +├─────────────────────────────────────────────────────────────────┤ |
| 62 | +│ Agent executes → reaches interrupt() │ |
| 63 | +│ ↓ │ |
| 64 | +│ LangGraph suspends execution │ |
| 65 | +│ ↓ │ |
| 66 | +│ State saved to __uipath/state.db │ |
| 67 | +│ ↓ │ |
| 68 | +│ Returns SUSPENDED status │ |
| 69 | +│ ↓ │ |
| 70 | +│ Python process can safely exit │ |
| 71 | +└─────────────────────────────────────────────────────────────────┘ |
| 72 | +
|
| 73 | + ... time passes, external work completes ... |
| 74 | +
|
| 75 | +┌─────────────────────────────────────────────────────────────────┐ |
| 76 | +│ 2. RESUME PHASE │ |
| 77 | +├─────────────────────────────────────────────────────────────────┤ |
| 78 | +│ New Python process starts │ |
| 79 | +│ ↓ │ |
| 80 | +│ Loads state from __uipath/state.db │ |
| 81 | +│ ↓ │ |
| 82 | +│ Invokes with Command(resume=result_data) │ |
| 83 | +│ ↓ │ |
| 84 | +│ Execution continues from interrupt() │ |
| 85 | +│ ↓ │ |
| 86 | +│ Agent completes and returns final result │ |
| 87 | +└─────────────────────────────────────────────────────────────────┘ |
| 88 | +``` |
| 89 | + |
| 90 | +## Files in This Sample |
| 91 | + |
| 92 | +### Core Files |
| 93 | +- **`graph_simple.py`** - Simple agent demonstrating suspend/resume with dict payload |
| 94 | +- **`agent-simple.py`** - Symlink to graph_simple.py (referenced by uipath.json) |
| 95 | +- **`uipath.json`** - Agent configuration |
| 96 | +- **`langgraph.json`** - Graph definition for agent-simple |
| 97 | +- **`pyproject.toml`** - Python dependencies |
| 98 | + |
| 99 | +### Evaluation Files |
| 100 | +- **`evaluations/eval-sets/test_simple_no_auth.json`** - Test cases for suspend/resume |
| 101 | +- **`evaluations/evaluators/contains_evaluator.json`** - Evaluator checking completion |
| 102 | + |
| 103 | +## Running the Sample |
| 104 | + |
| 105 | +### Step 1: Suspend |
| 106 | + |
| 107 | +Run the evaluation without `--resume`: |
| 108 | + |
| 109 | +```bash |
| 110 | +uv run uipath eval agent-simple evaluations/eval-sets/test_simple_no_auth.json |
| 111 | +``` |
| 112 | + |
| 113 | +**What happens**: |
| 114 | +- Agent executes and calls `interrupt()` → suspends |
| 115 | +- State is saved to `__uipath/state.db` |
| 116 | +- Evaluation runtime detects SUSPENDED status |
| 117 | +- Process exits |
| 118 | + |
| 119 | +**Expected output**: |
| 120 | +``` |
| 121 | +EVAL RUNTIME: Resume mode: False |
| 122 | +🔴 EVAL RUNTIME: DETECTED SUSPENSION |
| 123 | +EVAL RUNTIME: Agent returned SUSPENDED status |
| 124 | +EVAL RUNTIME: Extracted trigger(s) from suspended execution |
| 125 | +✓ Basic suspend/resume with query - No evaluators |
| 126 | +``` |
| 127 | + |
| 128 | +### Step 2: Resume |
| 129 | + |
| 130 | +Resume execution with the `--resume` flag and provide input override: |
| 131 | + |
| 132 | +```bash |
| 133 | +uv run uipath eval agent-simple evaluations/eval-sets/test_simple_no_auth.json --resume --input-overrides '{"query": "Test suspend with simple payload"}' |
| 134 | +``` |
| 135 | + |
| 136 | +**What happens**: |
| 137 | +- Loads state from `__uipath/state.db` |
| 138 | +- Continues execution from `interrupt()` |
| 139 | +- Agent completes and returns result |
| 140 | +- Evaluators run on final output |
| 141 | + |
| 142 | +**Expected output**: |
| 143 | +``` |
| 144 | +EVAL RUNTIME: Resume mode: True |
| 145 | +🟢 AGENT NODE: Execution RESUMED after interrupt() |
| 146 | +AGENT NODE: Received resume data: ... |
| 147 | +✓ Basic suspend/resume with query - Completed with resume data |
| 148 | +``` |
| 149 | + |
| 150 | +## Key Components |
| 151 | + |
| 152 | +### AsyncSqliteSaver |
| 153 | + |
| 154 | +Persists checkpoints to SQLite: |
| 155 | + |
| 156 | +```python |
| 157 | +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver |
| 158 | + |
| 159 | +async def _create_graph(): |
| 160 | + checkpointer = AsyncSqliteSaver.from_conn_string("__uipath/state.db") |
| 161 | + return builder.compile(checkpointer=checkpointer) |
| 162 | +``` |
| 163 | + |
| 164 | +### Thread ID |
| 165 | + |
| 166 | +Critical for resume - must match the suspend invocation. The evaluation runtime handles this automatically. |
| 167 | + |
| 168 | +### Command API |
| 169 | + |
| 170 | +The runtime uses the Command API to provide resume data: |
| 171 | + |
| 172 | +```python |
| 173 | +from langgraph.types import Command |
| 174 | + |
| 175 | +# Resume with specific data |
| 176 | +result = await graph.ainvoke( |
| 177 | + Command(resume={"status": "completed", "output": "success"}), |
| 178 | + config=config |
| 179 | +) |
| 180 | +``` |
| 181 | + |
| 182 | +## Agent Implementation |
| 183 | + |
| 184 | +The core agent (`graph_simple.py`) is simple: |
| 185 | + |
| 186 | +```python |
| 187 | +from langgraph.graph import StateGraph |
| 188 | +from langgraph.types import interrupt |
| 189 | +from pydantic import BaseModel |
| 190 | + |
| 191 | +class Input(BaseModel): |
| 192 | + query: str |
| 193 | + |
| 194 | +class Output(BaseModel): |
| 195 | + result: str |
| 196 | + |
| 197 | +class State(BaseModel): |
| 198 | + query: str |
| 199 | + result: str = "" |
| 200 | + |
| 201 | +async def suspend_node(state: State) -> State: |
| 202 | + """Node that suspends execution.""" |
| 203 | + # Interrupt with simple dict (no RPA invocation needed) |
| 204 | + resume_data = interrupt({ |
| 205 | + "message": "Waiting for external completion", |
| 206 | + "query": state.query |
| 207 | + }) |
| 208 | + |
| 209 | + # This code executes after resume |
| 210 | + result = f"Completed with resume data: {resume_data}" |
| 211 | + return {"query": state.query, "result": result} |
| 212 | + |
| 213 | +# Build the graph |
| 214 | +builder = StateGraph(state_schema=State) |
| 215 | +builder.add_node("suspend_node", suspend_node) |
| 216 | +builder.add_edge(START, "suspend_node") |
| 217 | +builder.add_edge("suspend_node", END) |
| 218 | + |
| 219 | +# Compile with AsyncSqliteSaver |
| 220 | +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver |
| 221 | + |
| 222 | +async def _create_graph(): |
| 223 | + checkpointer = AsyncSqliteSaver.from_conn_string("__uipath/state.db") |
| 224 | + return builder.compile(checkpointer=checkpointer) |
| 225 | +``` |
| 226 | + |
| 227 | +## Troubleshooting |
| 228 | + |
| 229 | +### "No checkpoint found" |
| 230 | +- Make sure you ran the suspend step first |
| 231 | +- Check that `__uipath/state.db` exists |
| 232 | +- Clean state between runs if needed: `rm -rf __uipath/state.db` |
| 233 | + |
| 234 | +### "Agent doesn't suspend" |
| 235 | +- Ensure you're using a checkpointer: `builder.compile(checkpointer=...)` |
| 236 | +- Check that `interrupt()` is actually called in your code |
| 237 | +- Look for SUSPENDED status in the output |
| 238 | + |
| 239 | +### "Resume starts from beginning" |
| 240 | +- Use `--resume` flag when resuming |
| 241 | +- Verify the state file exists at `__uipath/state.db` |
| 242 | + |
| 243 | +## Next Steps |
| 244 | + |
| 245 | +1. **Run the suspend step**: `uv run uipath eval agent-simple evaluations/eval-sets/test_simple_no_auth.json` |
| 246 | +2. **Run the resume step**: `uv run uipath eval agent-simple evaluations/eval-sets/test_simple_no_auth.json --resume --input-overrides '{"query": "Test suspend with simple payload"}'` |
| 247 | +3. **Build your own**: Use `graph_simple.py` as a template for your suspend/resume workflows |
| 248 | + |
| 249 | +## Resources |
| 250 | + |
| 251 | +- [LangGraph Interrupts Documentation](https://langchain-ai.github.io/langgraph/how-tos/human_in_the_loop/breakpoints/) |
| 252 | +- [UiPath Python SDK](https://github.com/UiPath/uipath-python) |
| 253 | +- [AsyncSqliteSaver Reference](https://langchain-ai.github.io/langgraph/reference/checkpoints/) |
0 commit comments