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

  1. Set ToolCallID on every tool-role message to the ID of the assistant tool call it answers
  2. Audit the corpus JSON for role:"tool" entries missing tool_call_id and backfill from the preceding assistant tool_calls[].id
  3. 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 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


AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15). Data as JSON: /api/errors/ef20c906aa8f8834. Report an issue: GitHub.