Chat model returns empty content when I inject a delayed ToolMessage from a scheduled callback

Hey!

I have a scheduler that triggers a tool function later (outside the original user–model exchange).
When the scheduled time comes, it sends a ToolMessage back into the chat model — but the model returns an empty message (content="") instead of responding.

This is the flow of messages:

[HumanMessage] → [AIMessage] → [ToolMessage] → [AIMessage] → … → [ToolMessage (from scheduled function)]

The final ToolMessage is the one created when the scheduled callback fires.
That’s where the issue happens — the model simply returns an empty string.

The Scheduled Callback:

def _task_reminder_callback(thread_id: str, task_description: str):
    print(f"\n\n === AGENT: TASK REMINDER! (for thread: {thread_id}) === ")
    print(f" TASK: {task_description} ")
    print(" ======================================================= \n\n")

    config = {"configurable": {"thread_id": thread_id, "user_id": "user_123"}}
    workflow_instance = app_state.get("WORKFLOW")

    state = workflow_instance.get_state(config)

    # Find the most recent tool call for schedule_reminder
    tool_call_id = None
    if state and state.values and "messages" in state.values:
        for msg in reversed(state.values["messages"]):
            if hasattr(msg, 'tool_calls') and msg.tool_calls:
                for tool_call in msg.tool_calls:
                    if tool_call.get('name') == 'schedule_reminder':
                        tool_call_id = tool_call.get('id')
                        break
            if tool_call_id:
                break

    print(f'[DEBUG] tool_call_id: {tool_call_id}')

    if workflow_instance:
        try:
            reminder_message = ToolMessage(
                content=(
                    f"REMINDER: generate a natural sounding message to remind user "
                    f"that it's time to do this task:\n\n {task_description}.\n\n"
                ),
                tool_call_id=tool_call_id
            )

            result = workflow_instance.invoke({"messages": reminder_message}, config=config)

            print(f" === Successfully invoked workflow for reminder in thread {thread_id} === \n\n {result} === \n\n")
        except Exception as e:
            print(f" !!! ERROR in _task_reminder_callback for thread {thread_id}: {e} !!! \n\n")
    else:
        print(f" !!! ERROR: WORKFLOW not initialized. Cannot run reminder for thread {thread_id} !!!")

Problem
When this scheduled function runs and I call workflow_instance.invoke() with a ToolMessage,
the chat model returns an empty content message AIMessage(content="") — it doesn’t “react” like it normally would when the tool message comes directly during conversation flow.
Expected behavior:
The model should interpret the tool message and respond naturally (e.g., “Hey, it’s time to do your scheduled task!”).

My Question:
How can I correctly re-inject a delayed ToolMessage into the workflow so that the model responds like it does during normal tool use?

Is there something special about how the tool_call_id or message history needs to be linked so the model knows what to do?
Or do I need to send a different type of message (like SystemMessage or AssistantMessage) when reactivating the model after a delay?

Hi @vicky70

have you tried this?

# 1) Build the ToolMessage with the SAME tool_call_id produced earlier by the model
reminder_message = ToolMessage(
    content=(
        f"REMINDER: generate a natural sounding message to remind the user "
        f"that it's time to do this task:\n\n{task_description}.\n\n"
    ),
    tool_call_id=tool_call_id,
    name="schedule_reminder",  # include tool name for clarity
)

# 2) Append it to the thread state AS IF it came from the 'tools' node
next_config = workflow_instance.update_state(
    config,
    {"messages": [reminder_message]},
    as_node="tools",  # drives the graph to the agent/model next
)

# 3) Resume the graph so the model reacts to the tool output
result = workflow_instance.invoke(None, config=next_config)
1 Like

Hello @vicky70 , have you successfully implemented this behavior?
I also have a tool that returns a partial result from the agent call, but in a later moment I try to inject a ToolMessage (with same tool_call_id), but the model generates a empty content response.

I have tried the @pawel-twardziak approach, but the model also generates an empty response, even when updating the graph state as it was resuming the result from the tool node.

I have also used a time-travel approach, search for the state when the tool was originally called, overwriting the tool result, as if it was completed as a direct call, but this way I lose any message exchanged between the user and the agent.

I had better results creating the tool message and a HumanMessage that instructs the model to continue the conversation, both on the same input message array, but oddly the model seens to ignore the ToolMessage data and generate a response that is not present on the appended ToolMessage.

Example:

messages = [
                    ToolMessage(
                        content=ai_agent_prompt_dto.prompt,
                        tool_call_id=ai_agent_prompt_dto.context[
                            "tool_call_id"
                        ], 
                    ),
                    HumanMessage(content="You have received the tool delayed result. You must continue with the conversation."),
                ]

Hey @vicky70

This usually happens because the graph isn’t actually being resumed in the same execution context the model expects.

When you inject a delayed ToolMessage, the model will only react if:

  1. The tool_call_id matches exactly the one from the original AIMessage.tool_calls.

  2. The message is appended as coming from the "tools" node.

  3. The graph is resumed from the correct next edge (not restarted with a fresh invoke).

In LangGraph, a ToolMessage by itself doesn’t trigger reasoning — it only works if the graph state machine thinks it just finished executing a tool. If that edge isn’t active anymore (because time passed or the flow completed), the model can legitimately return AIMessage(content="").

That empty response usually means:

“There is no pending tool result expected in this state.”


Why your HumanMessage workaround behaves oddly

When you add:

HumanMessage("You have received the tool delayed result...")

you’re effectively starting a new reasoning turn.
So the model ignores the tool linkage and just treats it like fresh input.

That’s why it doesn’t behave like native tool completion.


What works more reliably in production

Instead of trying to “resume” the original tool call hours later:

:white_check_mark: Treat the reminder as a new user event
→ Inject a HumanMessage like:

“System reminder: It’s time to do X.”

and let the agent respond normally.

This avoids fighting the internal state machine.


Why this happens (architecturally)

LangGraph’s execution model assumes:

AI → tool_call → ToolMessage → AI (immediate continuation)

It is not designed for:

AI → tool_call → [hours later] → ToolMessage → AI

Once the run is complete, there is no suspended edge waiting for that tool result.


In short:
You’re not doing it wrong — you’re hitting a lifecycle limitation.
Delayed tool reinjection only works if the graph is still in a waiting state. Otherwise, treat it as a new conversational turn.