moeru-ai/airi · error · Error

Tool messages require a correlated tool result

Error message

Tool messages require a correlated tool result

What it means

In Chat Completions, a tool message only makes sense as the result of a preceding assistant tool_call, correlated by tool_call_id. During rendering, non-text segments accumulate into content parts; if the entry's role is 'tool' and plain content parts are being flushed, the tool result has no correlated tool call, so the library throws to prevent emitting a malformed tool message.

Solutions

  1. Ensure every tool-role entry contains only tool-result segments, each with a callId matching a preceding assistant tool-call
  2. Move explanatory content into a user or assistant message before the tool result
  3. Regenerate the history via chatMessagesToProjectionEntries/chatMessagesToTurns so roles and segments are projected consistently

Example fix

// before
{ role: 'tool', segments: [{ type: 'text', text: 'result: ok' }] }
// after
{ role: 'tool', segments: [{ type: 'tool-result', callId: 'call_123', content: [{ type: 'text', text: 'result: ok' }] }] }
Defensive patterns

Strategy: validation

Validate before calling

function toolEntryIsValid(entry) {
  if (entry.role !== 'tool') return true
  return entry.segments.every(s => s.type === 'tool-result' && typeof s.callId === 'string')
}
if (!entries.every(toolEntryIsValid)) throw new Error('Tool entries must contain only correlated tool-result segments')

Type guard

function isToolResultSegment(seg) {
  return seg.type === 'tool-result' && typeof seg.callId === 'string' && seg.callId.length > 0
}
function isWellFormedToolEntry(entry) {
  return entry.role !== 'tool' || entry.segments.length > 0 && entry.segments.every(isToolResultSegment)
}

Try / catch

try {
  return conversationToChatMessages(conversation, supportsContentArray)
} catch (err) {
  if (err.message === 'Tool messages require a correlated tool result') {
    console.error('Malformed tool entry in conversation:', JSON.stringify(conversation.turns, null, 2))
  }
  throw err
}

Prevention

When it happens

Trigger: Calling conversationToChatMessages (through renderEntry) with a ProjectionEntry of role 'tool' whose segments include non-tool-result segments (e.g. text/image segments alongside or instead of a tool-result segment), so flush() encounters parts under role 'tool'.

Common situations: Constructing projection entries manually with role 'tool' plus explanatory text; a conversion step mislabeling user content as tool output; merging segments where a tool result lost its callId/tool-call counterpart; history edits that removed the assistant tool_call but kept the tool message.

Understand the failure class

Background: "Must be a positive integer", "Invalid value", "Unsupported": the invalid-argument-value error family, when a library rejects the value you pass — this error's family across 35 libraries.

Related errors


AI-assisted analysis of moeru-ai/airi@438a067dde (2026-09-17). Data as JSON: /api/errors/a651eb38664fb500. Report an issue: GitHub.

Appendix: source

Thrown at packages/core-agent/src/messages/chat-completions.ts:105

function renderEntry(message: ProjectionEntry, supportsContentArray: boolean): ChatMessage[] {
  const result: ChatMessage[] = []
  const role = message.role === 'context' || message.role === 'event' || message.role === 'summary' ? 'user' : message.role
  let parts: CommonContentPart[] = []
  let assistantParts: Array<{ type: 'text', text: string } | { type: 'refusal', refusal: string }> = []
  let calls: NonNullable<Extract<ChatMessage, { role: 'assistant' }>['tool_calls']> = []
  function flush() {
    if (role === 'assistant') {
      if (assistantParts.length || calls.length) {
        const content = !supportsContentArray || assistantParts.every(part => part.type === 'text')
          ? assistantParts.map(part => part.type === 'text' ? part.text : part.refusal).join('')
          : assistantParts
        result.push({ role, content, ...(calls.length ? { tool_calls: calls } : {}) })
      }
    }
    else if (parts.length) {
      if (role === 'tool')
        throw new Error('Tool messages require a correlated tool result')
      if (role === 'system' || role === 'developer') {
        if (parts.some(part => part.type !== 'text'))
          throw new Error(`${role} messages require text content`)
        result.push({ role, content: parts.map(part => part.type === 'text' ? part.text : '').join('') })
      }
      else {
        const content = !supportsContentArray || parts.every(part => part.type === 'text')
          ? parts.map(part => part.type === 'text' ? part.text : '').join('')
          : parts
        result.push({ role, content })
      }
    }
    parts = []
    assistantParts = []
    calls = []
  }
  for (const segment of message.segments) {
    if (segment.type === 'tool-result') {

View on GitHub (pinned to 438a067dde)