moeru-ai/airi · error · Error

Tool messages require a correlated tool result

Error message

Tool messages require a correlated tool result

What it means

During Responses-protocol projection, a message with role 'tool' accumulated plain text/image parts (flush was called with pending parts) instead of only containing tool-result segments. The OpenAI Responses API represents tool outputs as `function_call_output` items correlated by `call_id`, not as plain tool-role messages. This library throws because a tool-role text message cannot be represented as a valid Responses item.

Solutions

  1. Represent the tool output as a message with a segment of type 'tool-result' (with callId matching the preceding assistant 'tool-call' segment) instead of plain text segments on a 'tool' role message.
  2. If the message is really contextual text, change its role to 'user' (or 'context'/'event'/'summary', which project to 'user') so flush renders it as an input_text message.
  3. Drop or transform the offending tool message before calling streamResponses, e.g. convert its text into a user message or remove the orphaned tool output.
  4. Check the source of the Conversation data: if it comes from persisted state, re-project it through readOutput/rendering so segments use the current model.

Example fix

// before: plain text on a tool-role message
{ id: 'm1', role: 'tool', segments: [{ type: 'text', text: '42' }] }

// after: correlated tool-result segment
{ id: 'm1', role: 'tool', segments: [{ type: 'tool-result', callId: 'call_1', content: [{ type: 'text', text: '42' }] }] }
Defensive patterns

Strategy: validation

Validate before calling

function isValidToolMessage(m) {
  return m.role !== 'tool' || m.segments.every(s => s.type === 'tool-result')
}
conversation.turns.flatMap(t => (t.type !== 'assistant' ? [projectInput(t)] : t.rounds.flatMap(projectRound))).forEach(m => { if (!isValidToolMessage(m)) throw new Error('tool message must only contain tool-result segments') })

Type guard

const isToolResultSegment = (s) => s.type === 'tool-result'
const hasPlainToolText = (m) => m.role === 'tool' && m.segments.some(s => s.type !== 'tool-result')

Try / catch

try {
  await streamResponses({ config, conversation, scope, onEvent })
} catch (e) {
  if (e.message === 'Tool messages require a correlated tool result') {
    // re-project or drop the offending tool message before retrying
  } else throw e
}

Prevention

When it happens

Trigger: Passing a Conversation/ProjectionEntry with role 'tool' whose segments include text/image segments rather than a single tool-result segment (renderMessage -> flush with role 'tool' and non-empty parts). Typically happens when hand-constructed messages use role 'tool' without the tool-result segment type, or after a version change in the internal message model where tool outputs moved into segments of type 'tool-result'.

Common situations: Migrating from a chat-completions-style history (role:'tool' with string content) into the core-agent Conversation model; importing persisted transcripts produced by an older runtime; manually appending tool messages to a Conversation instead of using the tool-call/tool-result segment pairs created by readOutput.

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/2b878191013d864b. Report an issue: GitHub.

Appendix: source

Thrown at packages/core-agent/src/runtime/responses.ts:40

    case 'image': return { type: 'input_image', image_url: segment.url, detail: segment.detail }
    case 'file':
      if (segment.providerFileId)
        throw new Error('Responses file references require native input Items')
      return { type: 'input_file', file_data: segment.data, filename: segment.name, file_url: segment.url }
    case 'audio': throw new Error('This Responses adapter does not support audio input')
    default: return { type: 'input_text', text: renderSegmentText(segment) }
  }
}

function renderMessage(message: ProjectionEntry): ItemParam[] {
  const items: ItemParam[] = []
  const role = message.role === 'context' || message.role === 'event' || message.role === 'summary' ? 'user' : message.role
  let parts: MessageSegment[] = []
  function flush() {
    if (!parts.length)
      return
    if (role === 'tool')
      throw new Error('Tool messages require a correlated tool result')
    if (role === 'assistant') {
      const content = parts.map(part => part.type === 'refusal'
        ? { type: 'refusal' as const, refusal: part.text }
        : { type: 'output_text' as const, text: renderSegmentText(part) })
      items.push({ type: 'message', role, content })
    }
    else if (role === 'user') {
      const content = parts.map(inputPart)
      items.push({ type: 'message', role, content: content.every(part => part.type === 'input_text') ? content.map(part => part.text).join('') : content })
    }
    else {
      items.push({ type: 'message', role, content: parts.map(part => ({ type: 'input_text', text: renderSegmentText(part) })) })
    }
    parts = []
  }
  for (const segment of message.segments) {
    if (segment.type === 'tool-call') {
      if (role !== 'assistant')

View on GitHub (pinned to 438a067dde)