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
- 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.
- Ensure every code path in the hook, including early returns, returns the original ToolMessage unchanged when no modification is needed.
- If the hook only makes a decision (approve/deny), use the appropriate decision-returning hook type rather than a post-tool message-transform hook.
- 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
- Every post-tool hook path must return a ToolMessage (use model_copy for edits)
- Return the original message unchanged when no modification is needed
- Add return type annotations to hook handlers
- Unit-test hooks with a non-mutating input asserting the return type
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
- Expected {expected.__name__}, got {type(decision).__name__}
- context.hooks_server_events must be a list of strings or nul
- hook_responses must be a JSON object.
- -32002
- SDK {name} tool is unavailable.
AI-assisted analysis of langchain-ai/deepagents@a1af029e6e (2026-08-29).
Data as JSON: /api/errors/a5f590b4c6a40b0d.
Report an issue: GitHub.