{"record":{"id":"a5f590b4c6a40b0d","repo":"langchain-ai/deepagents","slug":"post-tool-hooks-must-preserve-committed-toolmessag","errorCode":null,"errorMessage":"Post-tool hooks must preserve committed ToolMessage results","messagePattern":"Post-tool hooks must preserve committed ToolMessage results","errorType":"exception","errorClass":"TypeError","httpStatus":null,"severity":"error","filePath":"libs/code/deepagents_code/hooks/server_middleware.py","lineNumber":553,"sourceCode":"                continue\n            updated = self._maybe_post_tool_use(\n                call,\n                context,\n                gate,\n                config,\n                result,\n                duration_ms,\n            )\n            updated = self._maybe_subagent_stop(\n                call,\n                context,\n                gate,\n                config,\n                updated,\n            )\n            if not isinstance(updated, ToolMessage):\n                msg = \"Post-tool hooks must preserve committed ToolMessage results\"\n                raise TypeError(msg)\n            updates.append(updated)\n        state_update: dict[str, Any] = {\n            _PENDING_POST_TOOL_STATE_KEY: completed,\n        }\n        if updates:\n            state_update[\"messages\"] = updates\n        return state_update\n\n    def _after_model(\n        self,\n        state: ServerHooksState,\n        runtime: Runtime[ContextT],\n    ) -> dict[str, Any]:\n        gate = _session_gate(runtime.context)\n        precompact_enabled = _event_enabled(gate, HookEvent.PRE_COMPACT)\n        pretool_enabled = _event_enabled(gate, HookEvent.PRE_TOOL_USE)\n        if not precompact_enabled and not pretool_enabled:\n            return {_PRE_TOOL_STATE_KEY: {}}","sourceCodeStart":535,"sourceCodeEnd":571,"githubUrl":"https://github.com/langchain-ai/deepagents/blob/a1af029e6e73cb17c36bff823d227747b28e91e1/libs/code/deepagents_code/hooks/server_middleware.py#L535-L571","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before\ndef my_post_tool(message):\n    message.content = redact(message.content)\n    return message.content  # str -> TypeError\n\n// after\ndef my_post_tool(message: ToolMessage) -> ToolMessage:\n    return message.model_copy(update={\"content\": redact(message.content)})","handlingStrategy":"type-guard","validationCode":"result = my_post_tool_hook(original_message)\nif not isinstance(result, ToolMessage):\n    raise TypeError(\"post-tool hook must return a ToolMessage\")","typeGuard":"def is_tool_message(value: object) -> TypeGuard[ToolMessage]:\n    return isinstance(value, ToolMessage)","tryCatchPattern":"try:\n    agent.invoke(state, config)\nexcept TypeError as exc:\n    if \"must preserve committed ToolMessage\" in str(exc):\n        disable_hook(\"my_post_tool_hook\")  # and log","preventionTips":["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"],"tags":["hooks","type-error","middleware"],"backgroundTag":"hook-return-type-mismatch","analyzedSha":"a1af029e6e73cb17c36bff823d227747b28e91e1","analyzedAt":"2026-08-29T11:43:24.718Z","schemaVersion":2},"datasetVersion":"2026-08-29T12:17:43.993Z"}