{"record":{"id":"ef20c906aa8f8834","repo":"JuliusBrussee/caveman","slug":"tool-message-requires-tool-call-id","errorCode":null,"errorMessage":"tool message requires tool_call_id","messagePattern":"tool message requires tool_call_id","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"cacheengine/cachebench/corpus.go","lineNumber":383,"sourceCode":"\t\t\treturn errors.New(\"content must decode\")\n\t\t}\n\t\tif content != nil {\n\t\t\tif _, ok := content.(string); !ok {\n\t\t\t\treturn errors.New(\"content must be string or null\")\n\t\t\t}\n\t\t}\n\t}\n\tfor _, call := range message.ToolCalls {\n\t\tif call.Type != \"function\" || !validBoundedText(call.ID, 2048, false) || !validBoundedText(call.Function.Name, 512, false) || !validUniqueJSONObject([]byte(call.Function.Arguments)) {\n\t\t\treturn errors.New(\"tool call requires function type, id, function name, and JSON arguments\")\n\t\t}\n\t\tmessageBytes += len(call.ID) + len(call.Type) + len(call.Function.Name) + len(call.Function.Arguments)\n\t}\n\tif messageBytes > limits.MaxMessageBytes {\n\t\treturn errors.New(\"message exceeds byte limit\")\n\t}\n\tif message.Role == \"tool\" && message.ToolCallID == \"\" {\n\t\treturn errors.New(\"tool message requires tool_call_id\")\n\t}\n\treturn nil\n}\n\nfunc appendCorpusRow(rows *[]CorpusRow, sessions map[string]bool, retainedBytes *int64, row CorpusRow, limits CorpusLimits) error {\n\tif len(*rows) >= limits.MaxRows {\n\t\treturn fmt.Errorf(\"row limit %d exceeded\", limits.MaxRows)\n\t}\n\tif !sessions[row.SessionID] {\n\t\tif len(sessions) >= limits.MaxSessions {\n\t\t\treturn fmt.Errorf(\"session limit %d exceeded\", limits.MaxSessions)\n\t\t}\n\t\tsessions[row.SessionID] = true\n\t}\n\trowBytes := corpusRowRetainedBytes(row)\n\tif rowBytes > limits.MaxRetainedBytes-*retainedBytes {\n\t\treturn fmt.Errorf(\"retained corpus byte limit %d exceeded\", limits.MaxRetainedBytes)\n\t}","sourceCodeStart":365,"sourceCodeEnd":401,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/27d5a3981a347890211bb1bf2439e5c821a63bc9/cacheengine/cachebench/corpus.go#L365-L401","documentation":"A message with Role == \"tool\" must carry a non-empty ToolCallID because OpenAI-style tool results are correlated to their initiating tool call by that ID. The validator rejects any tool-role message with an empty ToolCallID, guaranteeing replay traces can reconstruct request/response pairing.","triggerScenarios":"Appending or validating a CorpusMessage{Role: \"tool\"} whose ToolCallID field is \"\" (or was never set after JSON decoding). Common when converting a chat log where tool responses were recorded without the originating call ID.","commonSituations":"Hand-migrated transcripts that dropped tool_call_id; corpus JSON where the field is spelled differently (toolCallId) and silently decodes to empty; trimming/flattening messages during preprocessing that loses the ID.","solutions":["Set ToolCallID on every tool-role message to the ID of the assistant tool call it answers","Audit the corpus JSON for role:\"tool\" entries missing tool_call_id and backfill from the preceding assistant tool_calls[].id","If the tool result is truly orphaned, drop the message rather than replaying it"],"exampleFix":"// before\nmsg := CorpusMessage{Role: \"tool\", Content: json.RawMessage(`\"ok\"`)}\n\n// after\nmsg := CorpusMessage{Role: \"tool\", ToolCallID: call.ID, Content: json.RawMessage(`\"ok\"`)}","handlingStrategy":"validation","validationCode":"func toolMessagesHaveCallID(rows []CorpusRow) error {\n    for _, row := range rows {\n        for _, m := range row.Messages {\n            if m.Role == \"tool\" && strings.TrimSpace(m.ToolCallID) == \"\" {\n                return fmt.Errorf(\"session %s: tool message without tool_call_id\", row.SessionID)\n            }\n        }\n    }\n    return nil\n}","typeGuard":"func isCompleteToolMessage(m CorpusMessage) bool {\n    return m.Role != \"tool\" || m.ToolCallID != \"\"\n}","tryCatchPattern":"if err := validateCorpusMessage(m, limits); err != nil {\n    if m.Role == \"tool\" && m.ToolCallID == \"\" {\n        m.ToolCallID = lookupPriorCallID(session, m) // backfill or drop\n    }\n}","preventionTips":["When converting chat logs, always copy tool_call_id from the preceding assistant tool_calls[].id","Add a corpus load-time lint that fails on tool-role messages with empty ToolCallID","Drop orphaned tool results instead of replaying them"],"tags":["go","corpus","validation","tool-calls"],"backgroundTag":null,"analyzedSha":"27d5a3981a347890211bb1bf2439e5c821a63bc9","analyzedAt":"2026-08-15T09:26:11.751Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}