langchain-ai/deepagents · error · TypeError

Post-tool hooks must preserve committed ToolMessage results

Error message

Post-tool hooks must preserve committed ToolMessage results

What it means

Post-tool-use hooks run after a ToolMessage has already been committed to the conversation state. A hook that returns something other than a ToolMessage would corrupt the message history, so the middleware validates the transformed result and raises TypeError if the hook's return value does not preserve the committed ToolMessage.

Source

Thrown at libs/code/deepagents_code/hooks/server_middleware.py:553

                continue
            updated = self._maybe_post_tool_use(
                call,
                context,
                gate,
                config,
                result,
                duration_ms,
            )
            updated = self._maybe_subagent_stop(
                call,
                context,
                gate,
                config,
                updated,
            )
            if not isinstance(updated, ToolMessage):
                msg = "Post-tool hooks must preserve committed ToolMessage results"
                raise TypeError(msg)
            updates.append(updated)
        state_update: dict[str, Any] = {
            _PENDING_POST_TOOL_STATE_KEY: completed,
        }
        if updates:
            state_update["messages"] = updates
        return state_update

    def _after_model(
        self,
        state: ServerHooksState,
        runtime: Runtime[ContextT],
    ) -> dict[str, Any]:
        gate = _session_gate(runtime.context)
        precompact_enabled = _event_enabled(gate, HookEvent.PRE_COMPACT)
        pretool_enabled = _event_enabled(gate, HookEvent.PRE_TOOL_USE)
        if not precompact_enabled and not pretool_enabled:
            return {_PRE_TOOL_STATE_KEY: {}}

View on GitHub (pinned to a1af029e6e)

Solutions

  1. Change the hook handler to return a ToolMessage — construct one from the original (e.g. model_copy(update={...}) in pydantic) rather than a plain dict.
  2. Ensure every code path in the hook, including early returns, returns the original ToolMessage unchanged when no modification is needed.
  3. If the hook only makes a decision (approve/deny), use the appropriate decision-returning hook type rather than a post-tool message-transform hook.
  4. Catch TypeError around agent/stream execution and log the offending hook name for debugging.

Example fix

// before
def my_post_tool(message):
    message.content = redact(message.content)
    return message.content  # str -> TypeError

// after
def my_post_tool(message: ToolMessage) -> ToolMessage:
    return message.model_copy(update={"content": redact(message.content)})
Defensive patterns

Strategy: type-guard

Validate before calling

result = my_post_tool_hook(original_message)
if not isinstance(result, ToolMessage):
    raise TypeError("post-tool hook must return a ToolMessage")

Type guard

def is_tool_message(value: object) -> TypeGuard[ToolMessage]:
    return isinstance(value, ToolMessage)

Try / catch

try:
    agent.invoke(state, config)
except TypeError as exc:
    if "must preserve committed ToolMessage" in str(exc):
        disable_hook("my_post_tool_hook")  # and log

Prevention

When it happens

Trigger: A post-tool-use hook handler (run via _before_model's pending post-tool processing) returns a dict, string, None, or another message type instead of a ToolMessage; the middleware appends the updated message to `updates` and fails the isinstance check at server_middleware.py:553.

Common situations: Writing a custom post-tool hook that returns the hook's decision object instead of the message; forgetting to rebuild the ToolMessage after mutating content; a hook that swallows the ToolMessage and returns None on a deny/skip path.

Related errors


AI-assisted analysis of langchain-ai/deepagents@a1af029e6e (2026-08-29). Data as JSON: /api/errors/a5f590b4c6a40b0d. Report an issue: GitHub.