moeru-ai/airi · error · Error

Only assistant messages can invoke tools

Error message

Only assistant messages can invoke tools

What it means

The Responses adapter requires that a segment of type 'tool-call' (rendered as a Responses `function_call` item) appears inside an assistant-role message. OpenAI's Responses API only allows the model/assistant to emit function calls, so a non-assistant message containing a tool-call segment is invalid and this library throws at renderMessage time.

Solutions

  1. Move the 'tool-call' segment into an assistant-role message; keep user/system messages free of tool-call segments.
  2. If the segment is actually the outcome of the call, use type 'tool-result' (with the matching callId) in the tool-role message instead of 'tool-call'.
  3. Fix the projection code (projectInput/projectRound or your Conversation builder) so assistant tool calls retain role 'assistant'.
  4. If importing history, re-emit the call as an assistant message followed by a tool message containing the correlated 'tool-result' segment.

Example fix

// before: tool call attached to a user message
{ id: 'm1', role: 'user', segments: [{ type: 'tool-call', callId: 'c1', name: 'getWeather', arguments: '{}' }] }

// after: assistant owns the call
{ id: 'm1', role: 'assistant', segments: [{ type: 'tool-call', callId: 'c1', name: 'getWeather', arguments: '{}' }] }
Defensive patterns

Strategy: validation

Validate before calling

function assertToolCallsAreAssistant(conversation) {
  for (const turn of conversation.turns) {
    const entries = turn.type !== 'assistant' ? [projectInput(turn)] : turn.rounds.flatMap(projectRound)
    for (const m of entries) {
      if (m.role !== 'assistant' && m.segments.some(s => s.type === 'tool-call'))
        throw new Error(`tool-call segment on non-assistant message (role=${m.role})`)
    }
  }
}

Type guard

const hasToolCall = (m) => m.segments.some(s => s.type === 'tool-call')
const renderable = (m) => !hasToolCall(m) || m.role === 'assistant'

Try / catch

try {
  await streamResponses({ config, conversation, scope, onEvent })
} catch (e) {
  if (e.message === 'Only assistant messages can invoke tools') {
    // re-attribute tool-call segments to an assistant message, then retry once
  } else throw e
}

Prevention

When it happens

Trigger: A ProjectionEntry whose role is 'user', 'tool', 'system', 'context', 'event', or 'summary' contains a segment with type 'tool-call'. Occurs when messages are constructed or merged manually, when transcript import mis-attributes a tool call to the user/system role, or when projection logic (projectInput/projectRound) maps a turn into the wrong role while keeping its segments.

Common situations: Importing chat logs where tool calls were stored under a generic role; custom projection code that flattens rounds and loses the assistant ownership of tool-call segments; buggy turn merging that concatenates a user message with an assistant tool call.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


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

Appendix: source

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

    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')
        throw new Error('Only assistant messages can invoke tools')
      flush()
      items.push({ type: 'function_call', call_id: segment.callId, name: segment.name, arguments: segment.arguments })
    }
    else if (segment.type === 'tool-result') {
      flush()
      const content = segment.content.map(inputPart)
      items.push({ type: 'function_call_output', call_id: segment.callId, output: content.every(part => part.type === 'input_text') ? content.map(part => part.text).join('') : content })
    }
    else {
      parts.push(segment)
    }
  }
  flush()
  return items
}

function renderConversation(conversation: Conversation, scope: string): ItemParam[] {
  return conversation.turns.flatMap((turn) => {

View on GitHub (pinned to 438a067dde)