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
- Move the 'tool-call' segment into an assistant-role message; keep user/system messages free of tool-call segments.
- 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'.
- Fix the projection code (projectInput/projectRound or your Conversation builder) so assistant tool calls retain role 'assistant'.
- 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
- Treat tool-call segments as assistant-owned by construction; have your conversation builder enforce it at insertion time.
- When merging or flattening messages, split tool-call segments out into their own assistant message.
- Test imported transcripts through renderConversation before sending them to the API.
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
- Tool messages require a correlated tool result
- Cannot replay an unfinished function call
- Responses continuation must contain an item array
- Responses file requires exactly one source
- Responses image output requires a URL
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)