{ "cells": [ { "cell_type": "markdown", "id": "07c57479", "metadata": {}, "source": [ "# Agents with Human-in-the-Loop\n", "\n", "We have an email assistant that uses a router to triage emails and then passes the email to the agent for response generation. We've also evaluated it. But do we fully *trust* it to manage our inbox autonomously? For such a sensitive task, human-in-the-loop (HITL) is important! Here we'll show how to add a human-in-the-loop to our email assistant so that we can review specific tool calls. \n", "\n", "![overview-img](img/overview_hitl.png)\n", "\n" ] }, { "cell_type": "markdown", "id": "c8f73f12", "metadata": {}, "source": [ "We're going to show how to make the graph *pause* at specific points and await human input.\n", "\n", "![overview-img](img/hitl_schematic.png)" ] }, { "cell_type": "markdown", "id": "52e3532e", "metadata": {}, "source": [ "#### Load Environment Variables" ] }, { "cell_type": "code", "execution_count": null, "id": "a57594a7", "metadata": {}, "outputs": [], "source": [ "from dotenv import load_dotenv\n", "load_dotenv(\"../.env\")" ] }, { "cell_type": "markdown", "id": "2566464d", "metadata": {}, "source": [ "## Adding HITL to our email assistant\n", "\n", "Let's add HITL to our email assistant. \n", "\n", "We can start with tools, just as we did before. \n", "\n", "But now, we'll add a new tool Question that allows the assistant to ask the user a question." ] }, { "cell_type": "code", "execution_count": null, "id": "6d4dfb07", "metadata": {}, "outputs": [], "source": [ "\n", "%load_ext autoreload\n", "%autoreload 2\n", "\n", "from typing import Literal\n", "from datetime import datetime\n", "from pydantic import BaseModel\n", "\n", "from langchain.chat_models import init_chat_model\n", "from langchain_core.tools import tool\n", "\n", "from langgraph.graph import StateGraph, START, END\n", "from langgraph.types import interrupt, Command\n", "\n", "from email_assistant.prompts import triage_system_prompt, triage_user_prompt, agent_system_prompt_hitl, default_background, default_triage_instructions, default_response_preferences, default_cal_preferences\n", "from email_assistant.tools.default.prompt_templates import HITL_TOOLS_PROMPT\n", "from email_assistant.schemas import State, RouterSchema, StateInput\n", "from email_assistant.utils import parse_email, format_for_display, format_email_markdown\n", "\n", "# Agent tools \n", "@tool\n", "def write_email(to: str, subject: str, content: str) -> str:\n", " \"\"\"Write and send an email.\"\"\"\n", " # Placeholder response - in real app would send email\n", " return f\"Email sent to {to} with subject '{subject}' and content: {content}\"\n", "\n", "@tool\n", "def schedule_meeting(\n", " attendees: list[str], subject: str, duration_minutes: int, preferred_day: datetime, start_time: int\n", ") -> str:\n", " \"\"\"Schedule a calendar meeting.\"\"\"\n", " # Placeholder response - in real app would check calendar and schedule\n", " date_str = preferred_day.strftime(\"%A, %B %d, %Y\")\n", " return f\"Meeting '{subject}' scheduled on {date_str} at {start_time} for {duration_minutes} minutes with {len(attendees)} attendees\"\n", "\n", "@tool\n", "def check_calendar_availability(day: str) -> str:\n", " \"\"\"Check calendar availability for a given day.\"\"\"\n", " # Placeholder response - in real app would check actual calendar\n", " return f\"Available times on {day}: 9:00 AM, 2:00 PM, 4:00 PM\"\n", "\n", "@tool\n", "# This is new! \n", "class Question(BaseModel):\n", " \"\"\"Question to ask user.\"\"\"\n", " content: str\n", " \n", "@tool\n", "class Done(BaseModel):\n", " \"\"\"E-mail has been sent.\"\"\"\n", " done: bool\n", "\n", "# All tools available to the agent\n", "tools = [\n", " write_email, \n", " schedule_meeting, \n", " check_calendar_availability, \n", " Question, \n", " Done,\n", "]\n", "\n", "tools_by_name = {tool.name: tool for tool in tools}\n", "\n", "# Initialize the LLM for use with router / structured output\n", "llm = init_chat_model(\"openai:gpt-4.1\", temperature=0.0)\n", "llm_router = llm.with_structured_output(RouterSchema) \n", "\n", "# Initialize the LLM, enforcing tool use (of any available tools) for agent\n", "llm = init_chat_model(\"openai:gpt-4.1\", temperature=0.0)\n", "llm_with_tools = llm.bind_tools(tools, tool_choice=\"required\")" ] }, { "cell_type": "code", "execution_count": null, "id": "bf05b260-9809-4f32-807b-abe1632e4181", "metadata": {}, "outputs": [], "source": [ "from rich.markdown import Markdown\n", "Markdown(HITL_TOOLS_PROMPT)" ] }, { "cell_type": "markdown", "id": "b9f8f334", "metadata": {}, "source": [ "#### Triage node\n", "\n", "We define a python function with our triage routing logic, just as we did before.\n", "\n", "But, if the classification is `notify`, we want to interrupt the graph to allow the user to review the email! \n", "\n", "So we go to a new node, `triage_interrupt_handler`." ] }, { "cell_type": "code", "execution_count": 26, "id": "65efb689", "metadata": {}, "outputs": [], "source": [ "def triage_router(state: State) -> Command[Literal[\"triage_interrupt_handler\", \"response_agent\", \"__end__\"]]:\n", " \"\"\"Analyze email content to decide if we should respond, notify, or ignore.\"\"\"\n", "\n", " # Parse the email input\n", " author, to, subject, email_thread = parse_email(state[\"email_input\"])\n", " user_prompt = triage_user_prompt.format(\n", " author=author, to=to, subject=subject, email_thread=email_thread\n", " )\n", "\n", " # Create email markdown for Agent Inbox in case of notification \n", " email_markdown = format_email_markdown(subject, author, to, email_thread)\n", "\n", " # Format system prompt with background and triage instructions\n", " system_prompt = triage_system_prompt.format(\n", " background=default_background,\n", " triage_instructions=default_triage_instructions\n", " )\n", "\n", " # Run the router LLM\n", " result = llm_router.invoke(\n", " [\n", " {\"role\": \"system\", \"content\": system_prompt},\n", " {\"role\": \"user\", \"content\": user_prompt},\n", " ]\n", " )\n", "\n", " # Decision\n", " classification = result.classification\n", "\n", " # Process the classification decision\n", " if classification == \"respond\":\n", " print(\"📧 Classification: RESPOND - This email requires a response\")\n", " # Next node\n", " goto = \"response_agent\"\n", " # Update the state\n", " update = {\n", " \"classification_decision\": classification,\n", " \"messages\": [{\"role\": \"user\",\n", " \"content\": f\"Respond to the email: {email_markdown}\"\n", " }],\n", " }\n", " elif classification == \"ignore\":\n", " print(\"🚫 Classification: IGNORE - This email can be safely ignored\")\n", " # Next node\n", " goto = END\n", " # Update the state\n", " update = {\n", " \"classification_decision\": classification,\n", " }\n", "\n", " elif classification == \"notify\":\n", " print(\"🔔 Classification: NOTIFY - This email contains important information\") \n", " # This is new! \n", " goto = \"triage_interrupt_handler\"\n", " # Update the state\n", " update = {\n", " \"classification_decision\": classification,\n", " }\n", "\n", " else:\n", " raise ValueError(f\"Invalid classification: {classification}\")\n", " return Command(goto=goto, update=update)" ] }, { "cell_type": "markdown", "id": "4a1f564a", "metadata": {}, "source": [ "#### Triage Interrupt Handler\n", "\n", "If the decision is to `notify` the user, we interrupt the graph! \n", "\n", "![overview-img](img/HITL_flow_triage.png)\n", "\n", "For this, we add a new node, `triage_interrupt_handler`, that will: \n", "\n", "1. Show the classification to the user if it is `notify`: We'll pass a `dict` to the interrupt that contains our classification. \n", "2. Allow the user to respond to the decision: We'll design the code to handle what we will get back from Agent Inbox. \n", "\n", "As you can see [here](https://github.com/langchain-ai/agent-inbox?tab=readme-ov-file#what-do-the-fields-mean), we format our interrupt with specific fields so that it can be viewed in Agent Inbox:\n", "\n", "* `action_request`: The action and arguments for the interrupt with `action` (the action name) and `args` (the tool call arguments). This is rendered in the Agent Inbox as the main header for the interrupt event.\n", "* `config`: Configures which interaction types are allowed, and specific UI elements for each. \n", "* `description`: Should be detailed, and may be markdown. This will be rendered in the Agent Inbox as the description\n" ] }, { "cell_type": "code", "execution_count": 27, "id": "203346bb", "metadata": {}, "outputs": [], "source": [ "def triage_interrupt_handler(state: State) -> Command[Literal[\"response_agent\", \"__end__\"]]:\n", " \"\"\"Handles interrupts from the triage step.\"\"\"\n", " \n", " # Parse the email input\n", " author, to, subject, email_thread = parse_email(state[\"email_input\"])\n", "\n", " # Create email markdown for Agent Inbox in case of notification \n", " email_markdown = format_email_markdown(subject, author, to, email_thread)\n", "\n", " # Create messages\n", " messages = [{\"role\": \"user\",\n", " \"content\": f\"Email to notify user about: {email_markdown}\"\n", " }]\n", "\n", " # Create interrupt that is shown to the user\n", " request = {\n", " \"action_request\": {\n", " \"action\": f\"Email Assistant: {state['classification_decision']}\",\n", " \"args\": {}\n", " },\n", " \"config\": {\n", " \"allow_ignore\": True, \n", " \"allow_respond\": True, \n", " \"allow_edit\": False, \n", " \"allow_accept\": False, \n", " },\n", " # Email to show in Agent Inbox\n", " \"description\": email_markdown,\n", " }\n", "\n", " # Agent Inbox responds with a list of dicts with a single key `type` that can be `accept`, `edit`, `ignore`, or `response`. \n", " response = interrupt([request])[0]\n", "\n", " # If user provides feedback, go to response agent and use feedback to respond to email \n", " if response[\"type\"] == \"response\":\n", " # Add feedback to messages \n", " user_input = response[\"args\"]\n", " # Used by the response agent\n", " messages.append({\"role\": \"user\",\n", " \"content\": f\"User wants to reply to the email. Use this feedback to respond: {user_input}\"\n", " })\n", " # Go to response agent\n", " goto = \"response_agent\"\n", "\n", " # If user ignores email, go to END\n", " elif response[\"type\"] == \"ignore\":\n", " goto = END\n", "\n", " # Catch all other responses\n", " else:\n", " raise ValueError(f\"Invalid response: {response}\")\n", "\n", " # Update the state \n", " update = {\n", " \"messages\": messages,\n", " }\n", "\n", " return Command(goto=goto, update=update)" ] }, { "cell_type": "markdown", "id": "8613e4c4", "metadata": {}, "source": [ "#### LLM call\n", "\n", "The `llm_call` node is the same as before:" ] }, { "cell_type": "code", "execution_count": 28, "id": "036aba96", "metadata": {}, "outputs": [], "source": [ "def llm_call(state: State):\n", " \"\"\"LLM decides whether to call a tool or not.\"\"\"\n", "\n", " return {\n", " \"messages\": [\n", " llm_with_tools.invoke(\n", " [\n", " {\"role\": \"system\", \"content\": agent_system_prompt_hitl.format(tools_prompt=HITL_TOOLS_PROMPT, \n", " background=default_background,\n", " response_preferences=default_response_preferences, \n", " cal_preferences=default_cal_preferences)}\n", " ]\n", " + state[\"messages\"]\n", " )\n", " ]\n", " }" ] }, { "cell_type": "markdown", "id": "397516ee", "metadata": {}, "source": [ "#### Interrupt Handler\n", "\n", "The `interrupt_handler` is the core HITL component of our response agent. \n", "\n", "Its job is to examine the tool calls that the LLM wants to make and determine which ones need human review before execution. Here's how it works:\n", "\n", "1. **Tool Selection**: The handler maintains a list of \"HITL tools\" that require human approval:\n", " - `write_email`: Since sending emails has significant external impact\n", " - `schedule_meeting`: Since scheduling meetings affects calendars\n", " - `Question`: Since asking users questions requires direct interaction\n", "\n", "2. **Direct Execution**: Tools not in the HITL list (like `check_calendar_availability`) are executed immediately without interruption. This allows low-risk operations to proceed automatically.\n", "\n", "3. **Context Preparation**: For tools requiring review, the handler:\n", " - Retrieves the original email for context\n", " - Formats the tool call details for clear display\n", " - Configures which interaction types are allowed for each tool type\n", "\n", "4. **Interrupt Creation**: The handler creates a structured interrupt request with:\n", " - The action name and arguments\n", " - Configuration for allowed interaction types\n", " - A description that includes both the original email and the proposed action\n", "\n", "5. **Response Processing**: After the interrupt, the handler processes the human response:\n", " - **Accept**: Executes the tool with original arguments\n", " - **Edit**: Updates the tool call with edited arguments and then executes\n", " - **Ignore**: Cancels the tool execution\n", " - **Response**: Records feedback without execution\n", "\n", "This handler ensures humans have oversight of all significant actions while allowing routine operations to proceed automatically. \n", "\n", "The ability to edit tool arguments (like email content or meeting details) gives users precise control over the assistant's actions.\n", "\n", "We can visualize the overall flow: \n", "\n", "![overview-img](img/HITL_flow.png)" ] }, { "cell_type": "code", "execution_count": 29, "id": "f41929d5", "metadata": {}, "outputs": [], "source": [ "def interrupt_handler(state: State) -> Command[Literal[\"llm_call\", \"__end__\"]]:\n", " \"\"\"Creates an interrupt for human review of tool calls\"\"\"\n", " \n", " # Store messages\n", " result = []\n", "\n", " # Go to the LLM call node next\n", " goto = \"llm_call\"\n", "\n", " # Iterate over the tool calls in the last message\n", " for tool_call in state[\"messages\"][-1].tool_calls:\n", " \n", " # Allowed tools for HITL\n", " hitl_tools = [\"write_email\", \"schedule_meeting\", \"Question\"]\n", " \n", " # If tool is not in our HITL list, execute it directly without interruption\n", " if tool_call[\"name\"] not in hitl_tools:\n", "\n", " # Execute tool without interruption\n", " tool = tools_by_name[tool_call[\"name\"]]\n", " observation = tool.invoke(tool_call[\"args\"])\n", " result.append({\"role\": \"tool\", \"content\": observation, \"tool_call_id\": tool_call[\"id\"]})\n", " continue\n", " \n", " # Get original email from email_input in state\n", " email_input = state[\"email_input\"]\n", " author, to, subject, email_thread = parse_email(email_input)\n", " original_email_markdown = format_email_markdown(subject, author, to, email_thread)\n", " \n", " # Format tool call for display and prepend the original email\n", " tool_display = format_for_display(tool_call)\n", " description = original_email_markdown + tool_display\n", "\n", " # Configure what actions are allowed in Agent Inbox\n", " if tool_call[\"name\"] == \"write_email\":\n", " config = {\n", " \"allow_ignore\": True,\n", " \"allow_respond\": True,\n", " \"allow_edit\": True,\n", " \"allow_accept\": True,\n", " }\n", " elif tool_call[\"name\"] == \"schedule_meeting\":\n", " config = {\n", " \"allow_ignore\": True,\n", " \"allow_respond\": True,\n", " \"allow_edit\": True,\n", " \"allow_accept\": True,\n", " }\n", " elif tool_call[\"name\"] == \"Question\":\n", " config = {\n", " \"allow_ignore\": True,\n", " \"allow_respond\": True,\n", " \"allow_edit\": False,\n", " \"allow_accept\": False,\n", " }\n", " else:\n", " raise ValueError(f\"Invalid tool call: {tool_call['name']}\")\n", "\n", " # Create the interrupt request\n", " request = {\n", " \"action_request\": {\n", " \"action\": tool_call[\"name\"],\n", " \"args\": tool_call[\"args\"]\n", " },\n", " \"config\": config,\n", " \"description\": description,\n", " }\n", "\n", " # Send to Agent Inbox and wait for response\n", " response = interrupt([request])[0]\n", "\n", " # Handle the responses \n", " if response[\"type\"] == \"accept\":\n", "\n", " # Execute the tool with original args\n", " tool = tools_by_name[tool_call[\"name\"]]\n", " observation = tool.invoke(tool_call[\"args\"])\n", " result.append({\"role\": \"tool\", \"content\": observation, \"tool_call_id\": tool_call[\"id\"]})\n", " \n", " elif response[\"type\"] == \"edit\":\n", "\n", " # Tool selection \n", " tool = tools_by_name[tool_call[\"name\"]]\n", " \n", " # Get edited args from Agent Inbox\n", " edited_args = response[\"args\"][\"args\"]\n", "\n", " # Update the AI message's tool call with edited content (reference to the message in the state)\n", " ai_message = state[\"messages\"][-1] # Get the most recent message from the state\n", " current_id = tool_call[\"id\"] # Store the ID of the tool call being edited\n", " \n", " # Create a new list of tool calls by filtering out the one being edited and adding the updated version\n", " # This avoids modifying the original list directly (immutable approach)\n", " updated_tool_calls = [tc for tc in ai_message.tool_calls if tc[\"id\"] != current_id] + [\n", " {\"type\": \"tool_call\", \"name\": tool_call[\"name\"], \"args\": edited_args, \"id\": current_id}\n", " ]\n", " \n", " # Create a new copy of the message with updated tool calls rather than modifying the original\n", " # This ensures state immutability and prevents side effects in other parts of the code\n", " # When we update the messages state key (\"messages\": result), the add_messages reducer will\n", " # overwrite existing messages by id and we take advantage of this here to update the tool calls.\n", " result.append(ai_message.model_copy(update={\"tool_calls\": updated_tool_calls}))\n", "\n", " # Update the write_email tool call with the edited content from Agent Inbox\n", " if tool_call[\"name\"] == \"write_email\":\n", " \n", " # Execute the tool with edited args\n", " observation = tool.invoke(edited_args)\n", " \n", " # Add only the tool response message\n", " result.append({\"role\": \"tool\", \"content\": observation, \"tool_call_id\": current_id})\n", " \n", " # Update the schedule_meeting tool call with the edited content from Agent Inbox\n", " elif tool_call[\"name\"] == \"schedule_meeting\":\n", " \n", " \n", " # Execute the tool with edited args\n", " observation = tool.invoke(edited_args)\n", " \n", " # Add only the tool response message\n", " result.append({\"role\": \"tool\", \"content\": observation, \"tool_call_id\": current_id})\n", " \n", " # Catch all other tool calls\n", " else:\n", " raise ValueError(f\"Invalid tool call: {tool_call['name']}\")\n", "\n", " elif response[\"type\"] == \"ignore\":\n", " if tool_call[\"name\"] == \"write_email\":\n", " # Don't execute the tool, and tell the agent how to proceed\n", " result.append({\"role\": \"tool\", \"content\": \"User ignored this email draft. Ignore this email and end the workflow.\", \"tool_call_id\": tool_call[\"id\"]})\n", " # Go to END\n", " goto = END\n", " elif tool_call[\"name\"] == \"schedule_meeting\":\n", " # Don't execute the tool, and tell the agent how to proceed\n", " result.append({\"role\": \"tool\", \"content\": \"User ignored this calendar meeting draft. Ignore this email and end the workflow.\", \"tool_call_id\": tool_call[\"id\"]})\n", " # Go to END\n", " goto = END\n", " elif tool_call[\"name\"] == \"Question\":\n", " # Don't execute the tool, and tell the agent how to proceed\n", " result.append({\"role\": \"tool\", \"content\": \"User ignored this question. Ignore this email and end the workflow.\", \"tool_call_id\": tool_call[\"id\"]})\n", " # Go to END\n", " goto = END\n", " else:\n", " raise ValueError(f\"Invalid tool call: {tool_call['name']}\")\n", " \n", " elif response[\"type\"] == \"response\":\n", " # User provided feedback\n", " user_feedback = response[\"args\"]\n", " if tool_call[\"name\"] == \"write_email\":\n", " # Don't execute the tool, and add a message with the user feedback to incorporate into the email\n", " result.append({\"role\": \"tool\", \"content\": f\"User gave feedback, which can we incorporate into the email. Feedback: {user_feedback}\", \"tool_call_id\": tool_call[\"id\"]})\n", " elif tool_call[\"name\"] == \"schedule_meeting\":\n", " # Don't execute the tool, and add a message with the user feedback to incorporate into the email\n", " result.append({\"role\": \"tool\", \"content\": f\"User gave feedback, which can we incorporate into the meeting request. Feedback: {user_feedback}\", \"tool_call_id\": tool_call[\"id\"]})\n", " elif tool_call[\"name\"] == \"Question\": \n", " # Don't execute the tool, and add a message with the user feedback to incorporate into the email\n", " result.append({\"role\": \"tool\", \"content\": f\"User answered the question, which can we can use for any follow up actions. Feedback: {user_feedback}\", \"tool_call_id\": tool_call[\"id\"]})\n", " else:\n", " raise ValueError(f\"Invalid tool call: {tool_call['name']}\")\n", "\n", " # Catch all other responses\n", " else:\n", " raise ValueError(f\"Invalid response: {response}\")\n", " \n", " # Update the state \n", " update = {\n", " \"messages\": result,\n", " }\n", "\n", " return Command(goto=goto, update=update)" ] }, { "cell_type": "markdown", "id": "164b0897", "metadata": {}, "source": [ "Now, let's compile the graph. " ] }, { "cell_type": "code", "execution_count": null, "id": "3b6d1013", "metadata": {}, "outputs": [], "source": [ "from email_assistant.utils import show_graph\n", "\n", "# Conditional edge function\n", "def should_continue(state: State) -> Literal[\"interrupt_handler\", \"__end__\"]:\n", " \"\"\"Route to tool handler, or end if Done tool called\"\"\"\n", " messages = state[\"messages\"]\n", " last_message = messages[-1]\n", " if last_message.tool_calls:\n", " for tool_call in last_message.tool_calls: \n", " if tool_call[\"name\"] == \"Done\":\n", " return END\n", " else:\n", " return \"interrupt_handler\"\n", "\n", "# Build workflow\n", "agent_builder = StateGraph(State)\n", "\n", "# Add nodes\n", "agent_builder.add_node(\"llm_call\", llm_call)\n", "agent_builder.add_node(\"interrupt_handler\", interrupt_handler)\n", "\n", "# Add edges\n", "agent_builder.add_edge(START, \"llm_call\")\n", "agent_builder.add_conditional_edges(\n", " \"llm_call\",\n", " should_continue,\n", " {\n", " \"interrupt_handler\": \"interrupt_handler\",\n", " END: END,\n", " },\n", ")\n", "\n", "# Compile the agent\n", "response_agent = agent_builder.compile()\n", "\n", "# Build overall workflow\n", "overall_workflow = (\n", " StateGraph(State, input=StateInput)\n", " .add_node(triage_router)\n", " .add_node(triage_interrupt_handler)\n", " .add_node(\"response_agent\", response_agent)\n", " .add_edge(START, \"triage_router\")\n", " \n", ")\n", "\n", "email_assistant = overall_workflow.compile()\n", "show_graph(email_assistant, xray=True)" ] }, { "cell_type": "markdown", "id": "d747dcda", "metadata": {}, "source": [ "#### Review of HITL Patterns\n", "\n", "**Triage Interruption** When an email is classified as \"notify\", the system interrupts to show the email to the human user\n", "- *User Decision*: User can choose to ignore the notification or provide feedback to respond to the email\n", "- *Flow Control*: If ignored, workflow ends; if user provides feedback, it flows to the Response Agent\n", "\n", "**Write Email**: System shows proposed email draft for human review\n", "- *User Decision and Flow Control*: ignore (end workflow), respond with feedback, accept draft as-is, or edit draft\n", "\n", "**Schedule Meeting**: System shows proposed meeting details for human review\n", "- *User Decision and Flow Control*: ignore (end workflow), respond with feedback, accept meeting details as-is, or edit details\n", "\n", "**Question**: System asks user a question to clarify information\n", "- *User Decision and Flow Control*: ignore (end workflow) or respond with an answer\n", "\n", "### Interrupts Allow Us to Review and Accept Tool Calls" ] }, { "cell_type": "code", "execution_count": null, "id": "c12b2097", "metadata": {}, "outputs": [], "source": [ "import uuid\n", "from langgraph.checkpoint.memory import InMemorySaver\n", "\n", "# Email to respond to\n", "email_input_respond = {\n", " \"to\": \"Lance Martin \",\n", " \"author\": \"Project Manager \",\n", " \"subject\": \"Tax season let's schedule call\",\n", " \"email_thread\": \"Lance,\\n\\nIt's tax season again, and I wanted to schedule a call to discuss your tax planning strategies for this year. I have some suggestions that could potentially save you money.\\n\\nAre you available sometime next week? Tuesday or Thursday afternoon would work best for me, for about 45 minutes.\\n\\nRegards,\\nProject Manager\"\n", "}\n", "\n", "# Compile the graph with checkpointer\n", "checkpointer = InMemorySaver()\n", "graph = overall_workflow.compile(checkpointer=checkpointer)\n", "thread_id_1 = uuid.uuid4()\n", "thread_config_1 = {\"configurable\": {\"thread_id\": thread_id_1}}\n", "\n", "# Run the graph until a tool call that we choose to interrupt\n", "print(\"Running the graph until the first interrupt...\")\n", "for chunk in graph.stream({\"email_input\": email_input_respond}, config=thread_config_1):\n", " # Inspect interrupt object if present\n", " if '__interrupt__' in chunk:\n", " Interrupt_Object = chunk['__interrupt__'][0]\n", " print(\"\\nINTERRUPT OBJECT:\")\n", " print(f\"Action Request: {Interrupt_Object.value[0]['action_request']}\")" ] }, { "cell_type": "markdown", "id": "5546ad46", "metadata": {}, "source": [ "What happened? We hit the [interrupt](https://langchain-ai.github.io/langgraph/concepts/interrupts/), which paused execution at the tool call. You can see the `action` (tool call name) and `args` (tool call arguments) that we interrupted displayed to the user.\n", "\n", "Now, how do we handle the interrupt? This is where the `Command` interface comes in. [The `Command` object has several powerful capabilities](https://langchain-ai.github.io/langgraph/how-tos/command/). We used it to direct the flow of the graph in prior notebooks: \n", "- `goto`: Specifies which node to route to next\n", "- `update`: Modifies the state before continuing execution\n", "\n", "Here, we'll use it to resume the graph from the interrupted state:\n", "- `resume`: Provides the value to return from the interrupt call\n", "\n", "We can return whatever value our graph is designed to handle. In our case, the graph is designed to handle a list of dicts with a single key `type` that can be `accept`, `edit`, `ignore`, or `response`. So, we can simply pass `{\"type\": \"accept\"}` to the `resume` argument in order to tell the graph that we accept the tool call." ] }, { "cell_type": "code", "execution_count": null, "id": "50b1f772", "metadata": {}, "outputs": [], "source": [ "from langgraph.types import Command\n", "\n", "print(f\"\\nSimulating user accepting the {Interrupt_Object.value[0]['action_request']} tool call...\")\n", "for chunk in graph.stream(Command(resume=[{\"type\": \"accept\"}]), config=thread_config_1):\n", " # Inspect interrupt object if present\n", " if '__interrupt__' in chunk:\n", " Interrupt_Object = chunk['__interrupt__'][0]\n", " print(\"\\nINTERRUPT OBJECT:\")\n", " print(f\"Action Request: {Interrupt_Object.value[0]['action_request']}\")" ] }, { "cell_type": "code", "execution_count": null, "id": "fd321c51", "metadata": {}, "outputs": [], "source": [ "print(f\"\\nSimulating user accepting the {Interrupt_Object.value[0]['action_request']} tool call...\")\n", "for chunk in graph.stream(Command(resume=[{\"type\": \"accept\"}]), config=thread_config_1):\n", " # Inspect interrupt object if present\n", " if '__interrupt__' in chunk:\n", " Interrupt_Object = chunk['__interrupt__'][0]\n", " print(\"\\nINTERRUPT OBJECT:\")\n", " print(f\"Action Request: {Interrupt_Object.value[0]['action_request']}\")" ] }, { "cell_type": "code", "execution_count": null, "id": "c77baa35", "metadata": {}, "outputs": [], "source": [ "state = graph.get_state(thread_config_1)\n", "for m in state.values['messages']:\n", " m.pretty_print()" ] }, { "cell_type": "markdown", "id": "86b1ba30", "metadata": {}, "source": [ "\n", "\n", "### Interrupts Allow Us to Edit Tool Calls\n", "\n", "This test demonstrates how human modification works in the HITL flow:\n", "1. We start with the same tax planning email as before\n", "2. The agent proposes a meeting with the same parameters\n", "3. This time, the user EDITS the meeting proposal to change:\n", " - Duration from 45 to 30 minutes\n", " - Meeting subject is made more concise\n", "4. The agent adapts to these changes when drafting the email\n", "5. The user further EDITS the email to be shorter and less formal\n", "6. The workflow completes with both modifications incorporated\n", "\n", "This scenario showcases one of the most powerful aspects of HITL: \n", "\n", "* Users can make precise modifications to agent actions before they are executed, ensuring the final outcome matches their preferences without having to handle all the details themselves." ] }, { "cell_type": "code", "execution_count": null, "id": "3bfca1b4", "metadata": {}, "outputs": [], "source": [ "# Same email as before\n", "email_input_respond = {\n", " \"to\": \"Lance Martin \",\n", " \"author\": \"Project Manager \",\n", " \"subject\": \"Tax season let's schedule call\",\n", " \"email_thread\": \"Lance,\\n\\nIt's tax season again, and I wanted to schedule a call to discuss your tax planning strategies for this year. I have some suggestions that could potentially save you money.\\n\\nAre you available sometime next week? Tuesday or Thursday afternoon would work best for me, for about 45 minutes.\\n\\nRegards,\\nProject Manager\"\n", "}\n", "\n", "# Compile the graph with new thread\n", "checkpointer = InMemorySaver()\n", "graph = overall_workflow.compile(checkpointer=checkpointer)\n", "thread_id_2 = uuid.uuid4()\n", "thread_config_2 = {\"configurable\": {\"thread_id\": thread_id_2}}\n", "\n", "# Run the graph until the first interrupt - will be classified as \"respond\" and the agent will create a write_email tool call\n", "print(\"Running the graph until the first interrupt...\")\n", "for chunk in graph.stream({\"email_input\": email_input_respond}, config=thread_config_2):\n", " # Inspect interrupt object if present\n", " if '__interrupt__' in chunk:\n", " Interrupt_Object = chunk['__interrupt__'][0]\n", " print(\"\\nINTERRUPT OBJECT:\")\n", " print(f\"Action Request: {Interrupt_Object.value[0]['action_request']}\")" ] }, { "cell_type": "markdown", "id": "706ac0a6", "metadata": {}, "source": [ "Edit the `schedule_meeting` tool call\n", "\n", "When the agent proposes the initial meeting schedule, we now simulate the user making modifications through the edit functionality. This demonstrates how the `edit` response type works:\n", "\n", "1. The user receives the same meeting proposal as in the previous test\n", "2. Instead of accepting, they modify the parameters:\n", " - Reducing duration from 45 to 30 minutes\n", " - Keeping the same day and time\n", "3. The `edit` response includes the complete set of modified arguments\n", "4. The interrupt handler replaces the original tool arguments with these edited ones\n", "5. The tool is executed with the user's modifications\n", "\n", "This shows how edit capability gives users precise control over agent actions while still letting the agent handle the execution details." ] }, { "cell_type": "code", "execution_count": null, "id": "7175fedb", "metadata": {}, "outputs": [], "source": [ "# Now simulate user editing the schedule_meeting tool call\n", "print(\"\\nSimulating user editing the schedule_meeting tool call...\")\n", "edited_schedule_args = {\n", " \"attendees\": [\"pm@client.com\", \"lance@company.com\"],\n", " \"subject\": \"Tax Planning Discussion\",\n", " \"duration_minutes\": 30, # Changed from 45 to 30\n", " \"preferred_day\": \"2025-05-06\",\n", " \"start_time\": 14 \n", "}\n", "\n", "for chunk in graph.stream(Command(resume=[{\"type\": \"edit\", \"args\": {\"args\": edited_schedule_args}}]), config=thread_config_2):\n", " # Inspect response_agent most recent message\n", " if 'response_agent' in chunk:\n", " chunk['response_agent']['messages'][-1].pretty_print()\n", " # Inspect interrupt object if present\n", " if '__interrupt__' in chunk:\n", " Interrupt_Object = chunk['__interrupt__'][0]\n", " print(\"\\nINTERRUPT OBJECT:\")\n", " print(f\"Action Request: {Interrupt_Object.value[0]['action_request']}\")" ] }, { "cell_type": "markdown", "id": "9757706b", "metadata": {}, "source": [ "Edit the `write_email` tool call\n", "\n", "After accepting the modified meeting schedule, the agent drafts an email reflecting the 30-minute duration. Now we demonstrate how editing works with email content:\n", "\n", "1. The agent has adapted its email to mention the shorter 30-minute duration\n", "2. We simulate the user wanting an even more significant change to the email:\n", " - Completely rewriting the content to be shorter and less formal\n", " - Changing the meeting day mentioned in the email (showing how users can correct agent mistakes)\n", " - Requesting confirmation rather than stating the meeting as definite\n", "3. The `edit` response contains the complete new email content\n", "4. The tool arguments are updated with this edited content\n", "5. The email is sent with the user's preferred wording\n", "\n", "This example shows the power of HITL for complex communication tasks - the agent handles the structure and initial content, while humans can refine tone, style, and substance." ] }, { "cell_type": "code", "execution_count": null, "id": "a0604d53", "metadata": {}, "outputs": [], "source": [ "# Now simulate user editing the write_email tool call\n", "print(\"\\nSimulating user editing the write_email tool call...\")\n", "edited_email_args = {\n", " \"to\": \"pm@client.com\",\n", " \"subject\": \"Re: Tax season let's schedule call\",\n", " \"content\": \"Hello Project Manager,\\n\\nThank you for reaching out about tax planning. I scheduled a 30-minute call next Thursday at 3:00 PM. Would that work for you?\\n\\nBest regards,\\nLance Martin\"\n", "}\n", "\n", "for chunk in graph.stream(Command(resume=[{\"type\": \"edit\", \"args\": {\"args\": edited_email_args}}]), config=thread_config_2):\n", " # Inspect response_agent most recent message\n", " if 'response_agent' in chunk:\n", " chunk['response_agent']['messages'][-1].pretty_print()\n", " # Inspect interrupt object if present\n", " if '__interrupt__' in chunk:\n", " Interrupt_Object = chunk['__interrupt__'][0]\n", " print(\"\\nINTERRUPT OBJECT:\")\n", " print(f\"Action Request: {Interrupt_Object.value[0]['action_request']}\")" ] }, { "cell_type": "markdown", "id": "ac279101", "metadata": {}, "source": [ "Look at the full message history, and see trace, to view the edited tool calls:\n", "\n", "https://smith.langchain.com/public/21769510-d57a-41e4-b5c7-0ddb23c237d8/r" ] }, { "cell_type": "code", "execution_count": null, "id": "6d3e9be7", "metadata": {}, "outputs": [], "source": [ "state = graph.get_state(thread_config_2)\n", "for m in state.values['messages']:\n", " m.pretty_print()" ] }, { "cell_type": "markdown", "id": "da4c39e9", "metadata": {}, "source": [ "### Interrupts Allow Us to Provide Feedback on Tool Calls\n", "\n", "This test set demonstrates the \"response\" capability - providing feedback without editing or accepting:\n", "\n", "1. First, we test feedback for meeting scheduling:\n", " - The user provides specific preferences (30 minutes instead of 45, and afternoon meetings)\n", " - The agent incorporates this feedback into a revised proposal\n", " - The user then accepts the revised meeting schedule\n", "\n", "2. Second, we test feedback for email drafting:\n", " - The user requests a shorter, less formal email with a specific closing statement\n", " - The agent completely rewrites the email according to this guidance\n", " - The user accepts the new draft\n", "\n", "3. Lastly, we test feedback for questions:\n", " - For the brunch invitation, the user answers the question with additional context\n", " - The agent uses this information to draft an appropriate email response\n", " - The workflow proceeds with the user's input integrated\n", "\n", "The \"response\" capability bridges the gap between acceptance and editing - users can guide the agent without having to write the full content themselves. This is especially powerful for:\n", "- Adjusting tone and style\n", "- Adding context the agent missed\n", "- Redirecting the agent's approach\n", "- Answering questions in a way that shapes the next steps" ] }, { "cell_type": "code", "execution_count": null, "id": "3c4b3517", "metadata": {}, "outputs": [], "source": [ "# Respond - Meeting Request Email\n", "email_input_respond = {\n", " \"to\": \"Lance Martin \",\n", " \"author\": \"Project Manager \",\n", " \"subject\": \"Tax season let's schedule call\",\n", " \"email_thread\": \"Lance,\\n\\nIt's tax season again, and I wanted to schedule a call to discuss your tax planning strategies for this year. I have some suggestions that could potentially save you money.\\n\\nAre you available sometime next week? Tuesday or Thursday afternoon would work best for me, for about 45 minutes.\\n\\nRegards,\\nProject Manager\"\n", "}\n", "\n", "# Compile the graph\n", "checkpointer = InMemorySaver()\n", "graph = overall_workflow.compile(checkpointer=checkpointer)\n", "thread_id_5 = uuid.uuid4()\n", "thread_config_5 = {\"configurable\": {\"thread_id\": thread_id_5}}\n", "\n", "# Run the graph until the first interrupt \n", "# Email will be classified as \"respond\" \n", "# Agent will create a schedule_meeting and write_email tool call\n", "print(\"Running the graph until the first interrupt...\")\n", "for chunk in graph.stream({\"email_input\": email_input_respond}, config=thread_config_5):\n", " # Inspect interrupt object if present\n", " if '__interrupt__' in chunk:\n", " Interrupt_Object = chunk['__interrupt__'][0]\n", " print(\"\\nINTERRUPT OBJECT:\")\n", " print(f\"Action Request: {Interrupt_Object.value[0]['action_request']}\")" ] }, { "cell_type": "markdown", "id": "ae2bea0a", "metadata": {}, "source": [ "Provide feedback for the `schedule_meeting` tool call\n", "\n", "Now we explore the feedback capability for meeting scheduling:\n", "\n", "1. The agent proposes the standard 45-minute meeting on Tuesday at 2:00 PM\n", "2. Instead of accepting or editing, we provide feedback in natural language\n", "3. Our feedback specifies two preferences:\n", " - Shorter meeting (30 minutes instead of 45)\n", " - Preference for afternoon meetings (after 2pm)\n", "4. The agent receives this feedback through the `response` type\n", "5. The interrupt handler adds this feedback as a message to the state\n", "6. The agent processes this feedback and generates a new tool call incorporating these preferences\n", "\n", "Unlike direct editing, which requires specifying the entire set of parameters, feedback allows users to express their preferences conversationally. The agent must then interpret this feedback and apply it appropriately to create a revised proposal." ] }, { "cell_type": "code", "execution_count": null, "id": "9a916e10", "metadata": {}, "outputs": [], "source": [ "print(f\"\\nSimulating user providing feedback for the {Interrupt_Object.value[0]['action_request']['action']} tool call...\")\n", "for chunk in graph.stream(Command(resume=[{\"type\": \"response\", \"args\": \"Please schedule this for 30 minutes instead of 45 minutes, and I prefer afternoon meetings after 2pm.\"}]), config=thread_config_5):\n", " # Inspect interrupt object if present\n", " if '__interrupt__' in chunk:\n", " Interrupt_Object = chunk['__interrupt__'][0]\n", " print(\"\\nINTERRUPT OBJECT:\")\n", " print(f\"Action Request: {Interrupt_Object.value[0]['action_request']}\")" ] }, { "cell_type": "markdown", "id": "cf35f1a2", "metadata": {}, "source": [ "Accept the `schedule_meeting` tool call after providing feedback" ] }, { "cell_type": "code", "execution_count": null, "id": "2727fb0e", "metadata": {}, "outputs": [], "source": [ "print(f\"\\nSimulating user accepting the {Interrupt_Object.value[0]['action_request']} tool call...\")\n", "for chunk in graph.stream(Command(resume=[{\"type\": \"accept\"}]), config=thread_config_5):\n", " # Inspect interrupt object if present\n", " if '__interrupt__' in chunk:\n", " Interrupt_Object = chunk['__interrupt__'][0]\n", " print(\"\\nINTERRUPT OBJECT:\")\n", " print(f\"Action Request: {Interrupt_Object.value[0]['action_request']}\")" ] }, { "cell_type": "markdown", "id": "3ca470c5", "metadata": {}, "source": [ "Now provide feedback for the `write_email` tool call\n", "\n", "After accepting the revised meeting schedule, the agent drafts an email. We now test feedback for email content:\n", "\n", "1. The agent's email is relatively formal and detailed\n", "2. We provide stylistic feedback requesting:\n", " - A shorter, more concise email\n", " - A less formal tone\n", " - A specific closing statement about looking forward to the meeting\n", "3. The agent processes this feedback to completely rewrite the email\n", "4. The new draft is much shorter, more casual, and includes the requested closing\n", "\n", "This demonstrates the power of natural language feedback for content creation:\n", "- Users don't need to rewrite the entire email themselves\n", "- They can provide high-level guidance on style, tone, and content\n", "- The agent handles the actual writing based on this guidance\n", "- The result better matches user preferences while preserving the essential information\n", "\n", "The message history shows both the original and revised emails, clearly showing how the feedback was incorporated." ] }, { "cell_type": "code", "execution_count": null, "id": "f5221d87", "metadata": {}, "outputs": [], "source": [ "print(f\"\\nSimulating user providing feedback for the {Interrupt_Object.value[0]['action_request']['action']} tool call...\")\n", "for chunk in graph.stream(Command(resume=[{\"type\": \"response\", \"args\": \"Shorter and less formal. Include a closing statement about looking forward to the meeting!\"}]), config=thread_config_5):\n", " # Inspect response_agent most recent message\n", " if 'response_agent' in chunk:\n", " chunk['response_agent']['messages'][-1].pretty_print()\n", " # Inspect interrupt object if present\n", " if '__interrupt__' in chunk:\n", " Interrupt_Object = chunk['__interrupt__'][0]\n", " print(\"\\nINTERRUPT OBJECT:\")\n", " print(f\"Action Request: {Interrupt_Object.value[0]['action_request']}\")" ] }, { "cell_type": "markdown", "id": "1266ec72", "metadata": {}, "source": [ "Accept the `write_email` tool call after providing feedback" ] }, { "cell_type": "code", "execution_count": null, "id": "0b4698c0", "metadata": {}, "outputs": [], "source": [ "print(f\"\\nSimulating user accepting the {Interrupt_Object.value[0]['action_request']} tool call...\")\n", "for chunk in graph.stream(Command(resume=[{\"type\": \"accept\"}]), config=thread_config_5):\n", " # Inspect interrupt object if present\n", " if '__interrupt__' in chunk:\n", " Interrupt_Object = chunk['__interrupt__'][0]\n", " print(\"\\nINTERRUPT OBJECT:\")\n", " print(f\"Action Request: {Interrupt_Object.value[0]['action_request']}\")" ] }, { "cell_type": "markdown", "id": "c270f52a", "metadata": {}, "source": [ "Look at the full message history, and see the trace:\n", "\n", "https://smith.langchain.com/public/57006770-6bb3-4e40-b990-143c373ebe60/r\n", "\n", "We can see that user feedback in incorporated into the tool calls. " ] }, { "cell_type": "code", "execution_count": null, "id": "1daf10d6", "metadata": {}, "outputs": [], "source": [ "state = graph.get_state(thread_config_5)\n", "for m in state.values['messages']:\n", " m.pretty_print()" ] }, { "cell_type": "markdown", "id": "1d964e36", "metadata": {}, "source": [ "### Interrupts Enable New Tools\n", "\n", "Now let's try an email that calls the `Question` tool to provide feedback\n", "\n", "Finally, we test how feedback works with the `Question` tool:\n", "\n", "1. For the brunch invitation email, the agent asks about preferred day and time\n", "2. Instead of ignoring, we provide a substantive response with additional context:\n", " - Confirming we want to invite the people mentioned\n", " - Noting we need to check which weekend works best\n", " - Adding information about needing a reservation\n", "3. The agent uses this information to:\n", " - Draft a comprehensive email response incorporating all our feedback\n", " - Notice we didn't provide a specific day/time, so it suggests checking the calendar\n", " - Include the detail about making a reservation\n", "4. The complete email reflects both the original request and our additional guidance\n", "\n", "This demonstrates how question responses can shape the entire workflow:\n", "- Questions let the agent gather missing information\n", "- User responses can include both direct answers and additional context\n", "- The agent integrates all this information into its next actions\n", "- The final outcome reflects the collaborative intelligence of both human and AI" ] }, { "cell_type": "code", "execution_count": null, "id": "8827632a", "metadata": {}, "outputs": [], "source": [ "# Respond\n", "email_input_respond = {\n", " \"to\": \"Lance Martin \",\n", " \"author\": \"Partner \",\n", " \"subject\": \"Dinner?\",\n", " \"email_thread\": \"Hey, do you want italian or indian tonight?\"}\n", "\n", "# Compile the graph\n", "checkpointer = InMemorySaver()\n", "graph = overall_workflow.compile(checkpointer=checkpointer)\n", "thread_id_6 = uuid.uuid4()\n", "thread_config_6 = {\"configurable\": {\"thread_id\": thread_id_6}}\n", "\n", "# Run the graph until the first interrupt\n", "print(\"Running the graph until the first interrupt...\")\n", "for chunk in graph.stream({\"email_input\": email_input_respond}, config=thread_config_6):\n", " # Inspect interrupt object if present\n", " if '__interrupt__' in chunk:\n", " Interrupt_Object = chunk['__interrupt__'][0]\n", " print(\"\\nINTERRUPT OBJECT:\")\n", " print(f\"Action Request: {Interrupt_Object.value[0]['action_request']}\")" ] }, { "cell_type": "markdown", "id": "1d9f7f1b", "metadata": {}, "source": [ "Provide feedback for the `Question` tool call" ] }, { "cell_type": "code", "execution_count": null, "id": "4979effd", "metadata": {}, "outputs": [], "source": [ "print(f\"\\nSimulating user providing feedback for the {Interrupt_Object.value[0]['action_request']['action']} tool call...\")\n", "for chunk in graph.stream(Command(resume=[{\"type\": \"response\", \"args\": \"Let's do indian.\"}]), config=thread_config_6):\n", " # Inspect interrupt object if present\n", " if '__interrupt__' in chunk:\n", " Interrupt_Object = chunk['__interrupt__'][0]\n", " print(\"\\nINTERRUPT OBJECT:\")\n", " print(f\"Action Request: {Interrupt_Object.value[0]['action_request']}\")" ] }, { "cell_type": "markdown", "id": "76b4ba9b", "metadata": {}, "source": [ "Accept the `write_email` tool call" ] }, { "cell_type": "code", "execution_count": null, "id": "bfd34ec2", "metadata": {}, "outputs": [], "source": [ "print(f\"\\nSimulating user accepting the {Interrupt_Object.value[0]['action_request']['action']} tool call...\")\n", "for chunk in graph.stream(Command(resume=[{\"type\": \"accept\"}]), config=thread_config_6):\n", " # Inspect response_agent most recent message\n", " if 'response_agent' in chunk:\n", " chunk['response_agent']['messages'][-1].pretty_print()\n", " # Inspect interrupt object if present\n", " if '__interrupt__' in chunk:\n", " Interrupt_Object = chunk['__interrupt__'][0]\n", " print(\"\\nINTERRUPT OBJECT:\")\n", " print(f\"Action Request: {Interrupt_Object.value[0]['action_request']}\")" ] }, { "cell_type": "markdown", "id": "e214fe9e", "metadata": {}, "source": [ "Look at the full message history, and see the trace:\n", "\n", "https://smith.langchain.com/public/f4c727c3-b1d9-47a5-b3d0-3451619db8a2/r\n", "\n", "We can see that user feedback in incorporated into the email response." ] }, { "cell_type": "code", "execution_count": null, "id": "070393eb", "metadata": {}, "outputs": [], "source": [ "state = graph.get_state(thread_config_6)\n", "for m in state.values['messages']:\n", " m.pretty_print()" ] }, { "cell_type": "markdown", "id": "0cbec016-f08a-4984-abb9-07f428f5e69f", "metadata": {}, "source": [ "### Deployment\n", "\n", "Let's create a local deployment of our email assistant with HITL from `src/email_assistant/email_assistant_hitl.py`. \n", " \n", "As before, run `langgraph dev`, select `email_assistant_hitl` in Studio, and submit the e-mail:" ] }, { "cell_type": "code", "execution_count": null, "id": "2609b7e4-2065-4641-a1e6-5960f399a5f5", "metadata": {}, "outputs": [], "source": [ "{\n", " \"author\": \"Alice Smith \",\n", " \"to\": \"John Doe \",\n", " \"subject\": \"Quick question about API documentation\",\n", " \"email_thread\": \"Hi John,\\nI was reviewing the API documentation for the new authentication service and noticed a few endpoints seem to be missing from the specs. Could you help clarify if this was intentional or if we should update the docs?\\nSpecifically, I'm looking at:\\n- /auth/refresh\\n- /auth/validate\\nThanks!\\nAlice\"\n", "}" ] }, { "cell_type": "markdown", "id": "e0fe48f4", "metadata": {}, "source": [ "Our server it stateless. Threads with a local deployment are simply saved to the local filesystem (`.langgraph_api` in the project folder).\n", "\n", "With a [hosted](https://langchain-ai.github.io/langgraph/tutorials/deployment/#other-deployment-options) deployment, threads stored in Postgres.\n", "\n", "Interrupted threads are threads with status 'interrupted', and we can see the interrupt in Studio: \n", "\n", "![studio-img](img/studio-interrupt.png)\n", "\n", "We'll use a custom interface to view these interrupted threads, [Agent Inbox](https://dev.agentinbox.ai/). \n", "\n", "This interface is a nice way to edit, approve, ignore, or provide feedback on specific actions taken by LangGraph agents. \n", "\n", "If you go to [dev.agentinbox.ai](https://dev.agentinbox.ai/), you can easily connect to the graph:\n", " * Graph name: the name from the `langgraph.json` file (`email_assistant_hitl`)\n", " * Graph URL: `http://127.0.0.1:2024/`\n", "\n", "All interrupted threads run will then be visible: \n", "\n", "![agent-inbox-img](img/agent-inbox.png)\n", "\n", "Agent Inbox simply uses a `Command` with `resume`, as [shown with the SDK](https://langchain-ai.github.io/langgraph/how-tos/human_in_the_loop/wait-user-input/#interacting-with-the-agent) above, the resume the graph." ] }, { "cell_type": "markdown", "id": "4dd416e5", "metadata": {}, "source": [] } ], "metadata": { "jupytext": { "cell_metadata_filter": "-all", "main_language": "python", "notebook_metadata_filter": "-all" }, "kernelspec": { "display_name": ".venv", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.11.6" } }, "nbformat": 4, "nbformat_minor": 5 }