Skip to main content

Handling "requires_action" Stalls in OpenAI Assistants API

 You have configured your Assistant, defined your function schemas, and initiated a run. The Assistant correctly identifies the intent and decides to call a function. However, the process never completes. The Run status hangs indefinitely in requires_action, and the final response is never generated.

This "zombie run" scenario is the single most common friction point in the OpenAI Assistants API. It typically occurs not because of an API outage, but due to a misalignment between the API's state machine and the developer's asynchronous logic flow.

This guide provides a root cause analysis of the requires_action stall and a robust, production-ready implementation in Node.js to resolve it.

The Root Cause: The Run Lifecycle and State Locking

To fix the stall, you must understand the "lock" mechanism of the Assistants API. The Run object is a state machine.

  1. In Progress: The model is reasoning.
  2. Requires Action: The model determines it needs external data (a tool call). It pauses execution and locks the run.
  3. The Stall: The API is now waiting for a specific HTTP POST request containing the results of the tool call (submitToolOutputs).

Until OpenAI receives this payload, the Run clock keeps ticking. If you do not submit outputs within 10 minutes (the expires_at threshold), the run fails.

The Async/Await Trap

In Node.js, the most frequent cause of this stall is improper handling of the asynchronous gap between detecting the action and submitting the output.

If your polling loop triggers the tool execution but does not await the result and the subsequent submission to OpenAI before polling again, the logic desynchronizes. You end up polling a run that is still waiting for you, while your code has likely moved on or crashed silently inside a non-awaited promise.

The Solution: A Robust Polling Pattern

The following implementation solves the stall by strictly enforcing sequential execution:

  1. Poll the run status.
  2. If requires_action, stop polling.
  3. Execute the requested tools.
  4. Submit outputs.
  5. Resume polling only after submission is confirmed.

Prerequisites

Ensure you have the latest OpenAI Node.js SDK installed:

npm install openai

The Implementation (Node.js)

This code demonstrates a handleRunExecution function that manages the entire lifecycle, preventing requires_action hangs.

import OpenAI from 'openai';

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

// A mock tool for this example
const tools = {
  get_crypto_price: async ({ symbol }) => {
    // Simulate API latency
    await new Promise(resolve => setTimeout(resolve, 500));
    return JSON.stringify({ symbol, price: 65000, currency: 'USD' });
  }
};

/**
 * Manages the Run lifecycle, handling tool execution and polling.
 * 
 * @param {string} threadId - The active Thread ID
 * @param {string} assistantId - The Assistant ID
 */
async function handleRunExecution(threadId, assistantId) {
  try {
    // 1. Create and start the run
    let run = await openai.beta.threads.runs.create(threadId, {
      assistant_id: assistantId,
    });

    console.log(`Run created: ${run.id}`);

    // 2. Start Polling Loop
    while (true) {
      // Check current status
      run = await openai.beta.threads.runs.retrieve(threadId, run.id);
      console.log(`Run status: ${run.status}`);

      // CASE: Run Completed
      if (run.status === 'completed') {
        const messages = await openai.beta.threads.messages.list(threadId);
        const lastMessage = messages.data[0].content[0].text.value;
        return lastMessage;
      }

      // CASE: Action Required (The Fix)
      if (run.status === 'requires_action') {
        console.log("Action required. Executing tools...");
        
        const toolCalls = run.required_action.submit_tool_outputs.tool_calls;
        const toolOutputs = [];

        // Execute all requested tools in parallel
        await Promise.all(toolCalls.map(async (toolCall) => {
          const functionName = toolCall.function.name;
          const args = JSON.parse(toolCall.function.arguments);

          if (tools[functionName]) {
            console.log(`Executing: ${functionName}`);
            try {
              const output = await tools[functionName](args);
              toolOutputs.push({
                tool_call_id: toolCall.id,
                output: output,
              });
            } catch (error) {
              // Crucial: Handle tool errors gracefully so the run doesn't hang
              console.error(`Tool error in ${functionName}:`, error);
              toolOutputs.push({
                tool_call_id: toolCall.id,
                output: JSON.stringify({ error: "Tool execution failed" }),
              });
            }
          } else {
            console.warn(`Function ${functionName} not found.`);
             toolOutputs.push({
                tool_call_id: toolCall.id,
                output: JSON.stringify({ error: "Function not found" }),
              });
          }
        }));

        // Submit outputs back to OpenAI to unlock the run
        if (toolOutputs.length > 0) {
          run = await openai.beta.threads.runs.submitToolOutputs(
            threadId,
            run.id,
            { tool_outputs: toolOutputs }
          );
          console.log("Tool outputs submitted. Resuming polling...");
          continue; // Immediately check status again
        }
      }

      // CASE: Errors or Expiration
      if (['failed', 'cancelled', 'expired'].includes(run.status)) {
        throw new Error(`Run ended with status: ${run.status}. Error: ${run.last_error?.message}`);
      }

      // Wait before next poll to avoid rate limits
      await new Promise(resolve => setTimeout(resolve, 1000));
    }

  } catch (error) {
    console.error("Critical Run Error:", error);
    throw error;
  }
}

Deep Dive: Why This Fix Works

1. The tool_call_id Mapping

The most technical requirement for submitToolOutputs is the tool_call_id. When the API returns requires_action, it provides a list of tool calls, each with a unique ID.

If you execute the function but return the data without this specific ID, OpenAI cannot map the result to the request. The run remains in requires_action until it expires. The code above explicitly maps toolCall.id to the output object.

2. Error Boundary Inside the Tool Loop

Notice the try/catch block inside the toolCalls.map.

If your internal API (e.g., your database or third-party service) throws an error, and you do not catch it, your Node.js process might crash or the promise chain might reject. Consequently, submitToolOutputs is never called. OpenAI is left waiting. By catching local errors and submitting a JSON error string back to the LLM, you allow the Assistant to see the error and attempt to gracefully recover (e.g., by apologizing to the user).

3. State Mutation on Submission

The line run = await openai.beta.threads.runs.submitToolOutputs(...) is critical. When you submit outputs, the API responds with the updated Run object.

The status of the run immediately transitions from requires_action back to queued or in_progress. We assign this result back to our run variable so the while loop continues tracking the correct state.

Python Implementation Notes

While the logic is identical, Python developers using the synchronous client often face blocking issues. If you are using Python, ensure you are not blocking your main event loop if you are serving this via an API (like FastAPI or Flask).

Here is the critical submission logic for Python:

# Python snippet for context
if run.status == 'requires_action':
    tool_outputs = []
    for tool_call in run.required_action.submit_tool_outputs.tool_calls:
        # ... execute your function logic here ...
        tool_outputs.append({
            "tool_call_id": tool_call.id,
            "output": str(function_output)
        })
    
    # Submission unlocks the run
    if tool_outputs:
        run = client.beta.threads.runs.submit_tool_outputs(
            thread_id=thread_id,
            run_id=run.id,
            tool_outputs=tool_outputs
        )

Common Pitfalls and Edge Cases

The "Stream" Confusion

OpenAI recently introduced streaming support for Assistants. If you use stream: true, the logic changes significantly. You listen for the requires_action event, rather than polling for status. However, the fundamental requirement remains: you must submit the tool_outputs to resume the stream.

Large Output Payloads

If your tool returns massive JSON objects (e.g., a 5MB database dump), you might hit token limits or timeout errors during submission.

  • Fix: Truncate data in your tool function before returning it. The LLM rarely needs the entire raw dataset.

Parallel Function Calling

The API defaults to calling multiple functions at once if applicable. The code provided above uses Promise.all to handle this. If you process them sequentially, you risk timing out the run execution window if the tools are slow. Always execute tools in parallel where possible.

Conclusion

The "requires_action" stall is rarely a bug in the OpenAI platform; it is a strict enforcement of the request-response lifecycle. By ensuring your application captures tool executions, handles internal errors, and strictly guarantees a submitToolOutputs call for every action request, you will eliminate these indefinite hangs.