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

State machine for Assistant's execution phases #677

Merged

Conversation

dghirardo
Copy link
Contributor

Issue #566

This pull request introduces a simplified state machine for managing the Assistant's execution phases. Inspired by the OpenAI Assistants model, this leaner approach enhances code readability and simplifies debugging.

Changes:

  • Introduced state management within the run method with possible states: in_progress, running_tools, completed, failed, and expired.
  • Refactored code into distinct methods following the Single Responsibility Principle (SRP), enhancing readability and ease of debugging.
  • Used case statements for state management to improve code clarity and avoid nested if statements.
  • Created a standard_role method for consistent message type handling across different LLMs, enabling case statements in process_latest_message to manage the execution flow based on the role of the last message.
  • Incorporated an expired status similar to OpenAI, transitioning to expired if tool output is not provided within 10 minutes to prevent application hang.
  • Fixed an issue where the content of a message was not included if the LLM responded with one or more tool calls.

About implementation:

After analyzing OpenAI's approach, I believe a specialized state machine gem is over-engineered for our needs. OpenAI initializes a thread and creates a job for each run, allowing state access during execution, which is ideal for asynchronous processing. In our synchronous context, a simpler state machine within the run method is sufficient, reducing complexity while maintaining clear state management.

Notes:

I have left several TODO notes in the code for further improvements and would appreciate your feedback on these points. I am open to discussing the implementation and any potential improvements you might suggest. Thank you!


# TODO: Should we return the final state along with the messages?
Copy link
Collaborator

@andreibondarev andreibondarev Jun 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's okay to skip for now. But I think the developer should be able to access it with:

assistant.state
#=> :in_progress

What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, adding a state attribute to the assistant could be a valid solution without modifying the return value of the method.

A note: In synchronous execution, assistant.state will always return the final state (:completed, :failed, or :expired) because during the run method, you cannot access the attribute. Therefore, you will never see :in_progress or :running_tools. Correct?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't you see the :running_tools (:requires_action)? For example:

irb(main):019> a.add_message content:"Latests news in politics?"
=>
[#<Langchain::Messages::OpenAIMessage:0x000000011f1de708 @content="You are a news reporter", @role="system", @tool_call_id=nil, @tool_calls=[]>,
 #<Langchain::Messages::OpenAIMessage:0x00000001202b2ae8 @content="Latests news in politics?", @role="user", @tool_call_id=nil, @tool_calls=[]>]
irb(main):020> a.run auto_tool_execution: false
I, [2024-06-25T13:10:51.775951 #77146]  INFO -- : [Langchain.rb] [Langchain::Assistant]: Sending a call to Langchain::LLM::OpenAI
=>
[#<Langchain::Messages::OpenAIMessage:0x000000011f1de708 @content="You are a news reporter", @role="system", @tool_call_id=nil, @tool_calls=[]>,
 #<Langchain::Messages::OpenAIMessage:0x00000001202b2ae8 @content="Latests news in politics?", @role="user", @tool_call_id=nil, @tool_calls=[]>,
 #<Langchain::Messages::OpenAIMessage:0x000000012011a370
  @content="",
  @role="assistant",
  @tool_call_id=nil,
  @tool_calls=
   [{"id"=>"call_MnJgY7DHwfPT1yPXRM1KRF1H",
     "type"=>"function",
     "function"=>{"name"=>"news_retriever__get_top_headlines", "arguments"=>"{\"category\":\"general\",\"q\":\"politics\",\"page_size\":5}"}}]>]
irb(main):021>

At this point the state would be :running_tools (or :requires_action), correct?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, as you can see in handle_llm_message, if auto tool execution is disabled, the return value is currently :completed. This is necessary to exit the loop (see run_finished?).

if last_message.tool_calls.any?
  auto_tool_execution ? :running_tools : :completed
else

As written in the TODO notes, I was thinking to implement a new state (like :requires_manual_action). Or we should find a way to exit while keeping the state requires_action. What do you think?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dghirardo My gut feeling is to simplify, so less states. Do you agree? It's much easier to add new states than to try to simplify it later.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One last question. When the assistant is initialized for the first time, what will state be set to? Nil?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. Either "new" or "initialized" or "ready". What do you think?

case state
when :in_progress
process_latest_message(auto_tool_execution)
when :running_tools
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if we should just copy how OpenAI calls it and name it :requires_action to be more aligned with that interface?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, sure, I agree!

when :running_tools
execute_tools
else
Langchain.logger.error("Unexpected state encountered: #{state}")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are some examples of when the execution would go here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed it. There can't be any other state different from the ones we have decided.

# @return [Symbol] The next state
def execute_tools
# TODO: Should we create a method parameter to let the user change the value of the tool timeout?
Timeout.timeout(600) { run_tools(thread.messages.last.tool_calls) }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is 600 seconds so 10 minutes, right? I was thinking of starting very lean and not putting these kinds of guardrails just yet.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's 10 minutes. I took inspiration from OpenAI's assistant, which does the same. If you confirm, I can remove this check and the :expired state as well.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, let's get rid of it!

@andreibondarev
Copy link
Collaborator

@dghirardo First off -- thank you for this PR! 🎉❤️

I left you a few comments but I had pulled it out, tested it out, and it was looking pretty good!

@andreibondarev
Copy link
Collaborator

andreibondarev commented Jun 25, 2024

@dghirardo I think I just realized our slight misunderstanding here. The way I was thinking about this is:

if (LLM returns tool_calls)
  state = :requires_action
  if auto_tool_execution == true
    # We automatically execute the tools
  else
    # Return the control back to the developer

The developer has an option to execute the tools manually and submit the output if they want to, e.g.: assistant.submit_tool_output(tool_call_id:, output:)

@dghirardo
Copy link
Contributor Author

Hi @andreibondarev, I just made a commit with the latest changes we discussed in the past few days. We should be good now!

@andreibondarev
Copy link
Collaborator

@dghirardo Wow, amazing work!

@andreibondarev andreibondarev merged commit 75b57f4 into patterns-ai-core:main Jun 28, 2024
5 checks passed
@dghirardo dghirardo deleted the feature/state-machine-assistant branch June 28, 2024 08:03
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

Successfully merging this pull request may close these issues.

2 participants