Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Interrupt() when invoked for the second time, failed to wait for the user input #3072

Open
4 tasks done
Saisiva123 opened this issue Jan 16, 2025 · 9 comments
Open
4 tasks done

Comments

@Saisiva123
Copy link

Checked other resources

  • This is a bug, not a usage question. For questions, please use GitHub Discussions.
  • I added a clear and detailed title that summarizes the issue.
  • I read what a minimal reproducible example is (https://stackoverflow.com/help/minimal-reproducible-example).
  • I included a self-contained, minimal example that demonstrates the issue INCLUDING all the relevant imports. The code run AS IS to reproduce the issue.

Example Code

@tool
def book_appointment(first_name: str, last_name: str, email: str, doctor_name: str, time: str, tool_call_id: Annotated[str, InjectedToolCallId] ):
    '''This is responsible to book an appointment using the first name, last name, email, doctor name and appointment time.'''

    if first_name and last_name and email and doctor_name and time:
        api_response = {'success': True, 'message': 'Successfully booked the appointment'}

        return ToolMessage(content=api_response.get('message', ''), name="Book_appointment_tool", tool_call_id = tool_call_id)

    else:
        return ToolMessage(content='Not able to book the appointment', name="Book_appointment_tool",
                           tool_call_id=tool_call_id)

@tool
def collect_information(tool_call_id: Annotated[str, InjectedToolCallId]): # This acts like transfer tool that transfers to ask_human_node
    '''This is responsible to collect the necessary information like the first name, last name, email, doctor name and appointment time from the user.'''

    return Command(goto='ask_human_node', update={'messages': [
        ToolMessage(content="Collecting required information from the user", tool_call_id=tool_call_id)]
    })

def call_node(state: MessagesState) -> Command[Literal['ask_human_node', '__end__']]:
    prompt = '''You are an appointment booking agent who will be responsible to collect the necessary information from the user while booking the appointment.
    
    You would be always require to have following details to book an appointment:
    => First name, last name, email, doctor name and appointment time.
    '''
    tools = [book_appointment]
    model = ChatOpenAI(model="gpt-4o", openai_api_key=os.getenv("OPEN_AI_API_KEY")).bind_tools(tools)

    messages = [SystemMessage(content=prompt)] + state['messages']

    response = model.invoke(messages)

    results = []

    if len(response.tool_calls) > 0:
        tool_names = {tool.name: tool for tool in tools}

        for tool_call in response.tool_calls:
            tool_ = tool_names[tool_call["name"]]
            tool_input_fields = tool_.get_input_schema().model_json_schema()[
                "properties"
            ]
            if "state" in tool_input_fields:
                tool_call = {**tool_call, "args": {**tool_call["args"], "state": state}}

            tool_response = tool_.invoke(tool_call)
            results.append(tool_response)

        if len(results) > 0:
            return results
        else:
            return Command(goto='call_node', update={'messages': [AIMessage(content=str(results))]})

    return Command(update={'messages': [response]})


def ask_human_node(state: MessagesState) -> Command[Literal['call_node']]:
    last_message = state['messages'][-1]

    user_response = interrupt({
        'id': str(uuid.uuid4()),
        'request': last_message
    })

    if user_response:
        return Command(goto='call_node',
                       resume={'messages': [HumanMessage(content=user_response, name="User_Response")] },
                       update={'messages': [HumanMessage(content=user_response, name="User_Response")] })


builder = StateGraph(MessagesState)
builder.add_node('call_node', call_node)
builder.add_node('ask_human_node', ask_human_node)

builder.add_edge(START, 'call_node')
builder.add_edge('call_node', END)

Error Message and Stack Trace (if applicable)

Description

I'm trying to collect the information from the user to book an appointment using some details that needs to be passed to the Book Appointment API.

During the graph execution, the interrupt() method within the ask_human_node is triggered for the first time to request details from the user. After the user submits the details, if any required information is missing, the ask_human_node attempts to gather the missing details using interrupt(). However, at this point, it does not pause the execution and instead continues using the previously cached value.

As mentioned in the documentation I know its the default behavior, but I REQUEST to please let us know how to make interrupt wait for the second time as well.

Image

System Info

python -m langchain_core.sys_info

@Saisiva123
Copy link
Author

@vbarda and @eyurtsev I believe this is the best explanation I can provide for the scenario, and I strongly suggest treating this as a priority bug. Resolving it would add significant value to LangGraph.

Many developers are encountering the same issue, so you might notice multiple related issues being created. Please understand the urgency and consider addressing this promptly.

Thanks a lot for your consideration and help.

@vbarda
Copy link
Collaborator

vbarda commented Jan 16, 2025

Could you make sure you're on the latest version of langgraph (0.2.63) -- we made some related changes and I believe the issue is already fixed. I slightly modified your example to make it work correctly (mostly around passing messages history correctly in call_node)

from langgraph.checkpoint.memory import MemorySaver
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain_core.tools.base import InjectedToolCallId
from langgraph.types import Command, interrupt
from langchain_core.messages import ToolMessage, SystemMessage, HumanMessage

from typing import Annotated, Literal
from langgraph.graph import MessagesState, StateGraph, START

import uuid

@tool
def book_appointment(first_name: str, last_name: str, email: str, doctor_name: str, time: str, tool_call_id: Annotated[str, InjectedToolCallId] ):
    '''This is responsible to book an appointment using the first name, last name, email, doctor name and appointment time.'''

    if first_name and last_name and email and doctor_name and time:
        api_response = {'success': True, 'message': 'Successfully booked the appointment'}
        return ToolMessage(content=api_response.get('message', ''), name="Book_appointment_tool", tool_call_id = tool_call_id)

    else:
        return ToolMessage(content='Not able to book the appointment', name="Book_appointment_tool",
                           tool_call_id=tool_call_id)


@tool
def collect_information(tool_call_id: Annotated[str, InjectedToolCallId]): # This acts like transfer tool that transfers to ask_human_node
    '''Use this to collect any missing information like the first name, last name, email, doctor name and appointment time from the user.'''
    return Command(
        goto='ask_human_node', 
        update={'messages': [
            ToolMessage(content="Collecting required information from the user", tool_call_id=tool_call_id)]
        })


def call_node(state: MessagesState) -> Command[Literal['ask_human_node', '__end__']]:
    prompt = '''You are an appointment booking agent who will be responsible to collect the necessary information from the user while booking the appointment.
    
    You would be always require to have following details to book an appointment:
    => First name, last name, email, doctor name and appointment time.
    '''
    tools = [book_appointment, collect_information]
    model = ChatOpenAI(model="gpt-4o", openai_api_key=os.getenv("OPEN_AI_API_KEY")).bind_tools(tools)

    messages = [SystemMessage(content=prompt)] + state['messages']
    response = model.invoke(messages)
    results = []

    if len(response.tool_calls) > 0:
        tool_names = {tool.name: tool for tool in tools}

        for tool_call in response.tool_calls:
            tool_ = tool_names[tool_call["name"]]
            tool_response = tool_.invoke(tool_call)
            results.append(tool_response)

        if all(isinstance(result, ToolMessage) for result in results):
            return Command(update={"messages": [response, *results]})

        elif len(results) > 0:
            return [{"messages": response}, *results]

    return Command(update={'messages': [response]})


def ask_human_node(state: MessagesState) -> Command[Literal['call_node']]:
    last_message = state['messages'][-1]

    user_response = interrupt({
        'id': str(uuid.uuid4()),
        'request': last_message.content
    })
    return Command(goto='call_node',
                   update={'messages': [HumanMessage(content=user_response, name="User_Response")] })


builder = StateGraph(MessagesState)
builder.add_node('call_node', call_node)
builder.add_node('ask_human_node', ask_human_node)

builder.add_edge(START, 'call_node')


checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "1"}}

graph.invoke({"messages": [("user", "book appointment for John Smith at Dr Jane Doe's office")]}, config)

# assuming the tool calls worked correctly, these correctly update and re-invoke the interrupts until all info is collected
graph.invoke(Command(resume="email is [email protected]"), config)
graph.invoke(Command(resume="appointment is at 1pm"), config)

@underclocked
Copy link

0.2.63 appears to resolve my issue with multiple interrupts in a subgraph as well.

@estherfc00
Copy link

Can anyone explain how does this work in production? I an unable to return the user's input required in a HITL from a frontend (like gradio), and follow the graph. In all the examples provided it is either a notebook or the user input comes from python's input() function, which is not ideal for production.

@vbarda
Copy link
Collaborator

vbarda commented Jan 17, 2025

Can anyone explain how does this work in production? I an unable to return the user's input required in a HITL from a frontend (like gradio), and follow the graph. In all the examples provided it is either a notebook or the user input comes from python's input() function, which is not ideal for production.

I would recommend checking out this conceptual doc https://langchain-ai.github.io/langgraph/concepts/human_in_the_loop/#interrupt. User input does not come from input(), but rather from via Command(resume)

@estherfc00
Copy link

estherfc00 commented Jan 17, 2025

Can anyone explain how does this work in production? I an unable to return the user's input required in a HITL from a frontend (like gradio), and follow the graph. In all the examples provided it is either a notebook or the user input comes from python's input() function, which is not ideal for production.

I would recommend checking out this conceptual doc https://langchain-ai.github.io/langgraph/concepts/human_in_the_loop/#interrupt. User input does not come from input(), but rather from via Command(resume)

Thanks for clarifying, but the issue persists. In a block of code like the following, how would you implement Comand() function?Having in mind that the input_message variable is what comes from the UI (the user's query). This variable must be both the first input to the graph and the one to be updated through Command when the HITL node is triggered.

Code example:

  def run_graph(input_message):
      response = graph.invoke({
          "messages": [HumanMessage(content=input_message)]
      })
      
      formatted_response = response['messages'][1].content
      formatted_response = formatted_response.replace("\\n", "\n")
      return formatted_response

In the documentation example what needs to be "merged" are both some_input and value_from_human, which must come from the same variable (my input_message from above) which is, essentially, the output from the UI

Code from documentation:

  thread_config = {"configurable": {"thread_id": "some_id"}}
  graph.invoke(some_input, config=thread_config)
  
  graph.invoke(Command(resume=value_from_human), config=thread_config)

If there is any other logic, I am all ears. Thanks in advance.

@underclocked
Copy link

The way I handle it is with a flag that tells me if the state of the graph is interrupted or completed. I persist that variable in my own data structure in my back end, then when a new message from the front end comes in I handle it with either graph.stream(state….) or graph.stream(Command(…) depending.

@eyurtsev
Copy link
Contributor

eyurtsev commented Jan 17, 2025

@estherfc00 consider using Q&A discussions for asking usage questions. At the moment, it does not appear that there is a bug with the API, but that there's an error in user code. If you're trying to get help please remember to include a minimal reproducible example -- this often makes it much easier to point out where the error in the code is as well!

I would recommend starting from the examples in the conceptual guide and then adapting them to your use case to figure out what doesn't work.

@vbarda
Copy link
Collaborator

vbarda commented Jan 21, 2025

@Saisiva123 is your issue resolved in the latest langgraph version?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants