{"record":{"id":"2aca340e7b1e1523","repo":"unslothai/unsloth","slug":"tool-call-id-is-only-valid-on-role-tool-messag","errorCode":null,"errorMessage":"\"tool_call_id\" is only valid on role=\"tool\" messages.","messagePattern":"\"tool_call_id\" is only valid on role=\"tool\" messages\\.","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"studio/backend/models/inference.py","lineNumber":1258,"sourceCode":"            \"Gemini reads `extra_content.google.thought_signature` \"\n            \"from assistant messages to replay text-part signatures.\"\n        ),\n    )\n\n    @field_validator(\"reasoning_content\", mode = \"before\")\n    @classmethod\n    def _ignore_non_string_reasoning(cls, value):\n        # This field used to be ignored as an unknown key. Some compatible\n        # gateways send structured reasoning, so declaring the string form must\n        # not turn those previously accepted requests into validation errors.\n        return value if isinstance(value, str) else None\n\n    @model_validator(mode = \"after\")\n    def _validate_role_shape(self) -> \"ChatMessage\":\n        if self.tool_calls is not None and self.role != \"assistant\":\n            raise ValueError('\"tool_calls\" is only valid on role=\"assistant\" messages.')\n        if self.tool_call_id is not None and self.role != \"tool\":\n            raise ValueError('\"tool_call_id\" is only valid on role=\"tool\" messages.')\n        if self.name is not None and self.role != \"tool\":\n            raise ValueError('\"name\" is only valid on role=\"tool\" messages.')\n\n        if self.role == \"tool\":\n            # tool_call_id resolution happens at ChatCompletionRequest scope.\n            # OpenAI accepts empty tool results (commands with no output);\n            # normalize to \"\" instead of a 400 agentic clients treat as fatal.\n            if self.content is None or self.content == []:\n                self.content = \"\"\n        elif self.role == \"assistant\":\n            # Post-Stop sentinel: collapse content=\"\" / [] to None.\n            if (self.content == \"\" or self.content == []) and not self.tool_calls:\n                self.content = None\n        else:  # \"user\" | \"system\"\n            if self.content is None or self.content == []:\n                raise ValueError(f'role=\"{self.role}\" messages require \"content\".')\n        return self\n","sourceCodeStart":1240,"sourceCodeEnd":1276,"githubUrl":"https://github.com/unslothai/unsloth/blob/203007d19051dcd2ae33876786d117c99f6b0368/studio/backend/models/inference.py#L1240-L1276","documentation":"Raised by the ChatMessage model validator when tool_call_id is present but role is not \"tool\". tool_call_id is the correlation key that links a tool result message back to the assistant's specific tool call, so it is only valid on role=\"tool\" messages.","triggerScenarios":"Sending {\"role\": \"assistant\", \"tool_call_id\": \"call_123\", \"content\": \"...\"} or a user message with tool_call_id to /v1/chat/completions. Typically a client that echoes the id on the wrong turn when building the tool-result reply.","commonSituations":"Agents that build the tool response but forget to switch role from assistant to tool; copy/paste of the assistant tool_calls turn with only the id renamed; templates that include tool_call_id on system messages as 'context'.","solutions":["Put tool_call_id only on the role=\"tool\" message that answers the call.","Keep the matching id on the assistant turn's tool_calls entry so the pair correlates.","Strip tool_call_id when logging/replaying messages outside a tool-result turn."],"exampleFix":"# before\nmessages.append({\"role\": \"assistant\", \"tool_call_id\": \"call_123\", \"content\": tool_output})\n\n# after\nmessages.append({\"role\": \"assistant\", \"tool_calls\": [{\"id\": \"call_123\", \"type\": \"function\", \"function\": {...}}]})\nmessages.append({\"role\": \"tool\", \"tool_call_id\": \"call_123\", \"content\": tool_output})","handlingStrategy":"type-guard","validationCode":null,"typeGuard":"def message_ok(msg: dict) -> bool:\n    if msg.get('tool_call_id') is not None:\n        return msg.get('role') == 'tool'\n    return True","tryCatchPattern":null,"preventionTips":["Pair every tool_call_id with the assistant turn's matching call id","Build tool replies with a helper that always sets role='tool'","Strip correlation ids when logging messages outside tool-result turns"],"tags":["pydantic","validation","openai","chat-completions","tool-call-id","agents"],"backgroundTag":null,"analyzedSha":"203007d19051dcd2ae33876786d117c99f6b0368","analyzedAt":"2026-08-15T02:48:39.846Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}