JuliusBrussee/caveman · error
tool message requires tool_call_id
Error message
tool message requires tool_call_id
What it means
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.
Source
Thrown at cacheengine/cachebench/corpus.go:383
return errors.New("content must decode")
}
if content != nil {
if _, ok := content.(string); !ok {
return errors.New("content must be string or null")
}
}
}
for _, call := range message.ToolCalls {
if call.Type != "function" || !validBoundedText(call.ID, 2048, false) || !validBoundedText(call.Function.Name, 512, false) || !validUniqueJSONObject([]byte(call.Function.Arguments)) {
return errors.New("tool call requires function type, id, function name, and JSON arguments")
}
messageBytes += len(call.ID) + len(call.Type) + len(call.Function.Name) + len(call.Function.Arguments)
}
if messageBytes > limits.MaxMessageBytes {
return errors.New("message exceeds byte limit")
}
if message.Role == "tool" && message.ToolCallID == "" {
return errors.New("tool message requires tool_call_id")
}
return nil
}
func appendCorpusRow(rows *[]CorpusRow, sessions map[string]bool, retainedBytes *int64, row CorpusRow, limits CorpusLimits) error {
if len(*rows) >= limits.MaxRows {
return fmt.Errorf("row limit %d exceeded", limits.MaxRows)
}
if !sessions[row.SessionID] {
if len(sessions) >= limits.MaxSessions {
return fmt.Errorf("session limit %d exceeded", limits.MaxSessions)
}
sessions[row.SessionID] = true
}
rowBytes := corpusRowRetainedBytes(row)
if rowBytes > limits.MaxRetainedBytes-*retainedBytes {
return fmt.Errorf("retained corpus byte limit %d exceeded", limits.MaxRetainedBytes)
}View on GitHub (pinned to 27d5a3981a)
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
Example fix
// before
msg := CorpusMessage{Role: "tool", Content: json.RawMessage(`"ok"`)}
// after
msg := CorpusMessage{Role: "tool", ToolCallID: call.ID, Content: json.RawMessage(`"ok"`)} Defensive patterns
Strategy: validation
Validate before calling
func toolMessagesHaveCallID(rows []CorpusRow) error {
for _, row := range rows {
for _, m := range row.Messages {
if m.Role == "tool" && strings.TrimSpace(m.ToolCallID) == "" {
return fmt.Errorf("session %s: tool message without tool_call_id", row.SessionID)
}
}
}
return nil
} Type guard
func isCompleteToolMessage(m CorpusMessage) bool {
return m.Role != "tool" || m.ToolCallID != ""
} Try / catch
if err := validateCorpusMessage(m, limits); err != nil {
if m.Role == "tool" && m.ToolCallID == "" {
m.ToolCallID = lookupPriorCallID(session, m) // backfill or drop
}
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- cachebench: nil corpus reader
- message exceeds byte limit
- cachebench: invalid corpus
- cachebench: no providers
- cachebench: corpus has no sessions
AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15).
Data as JSON: /api/errors/ef20c906aa8f8834.
Report an issue: GitHub.