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
- Ensure every tool-role entry contains only tool-result segments, each with a callId matching a preceding assistant tool-call
- Move explanatory content into a user or assistant message before the tool result
- 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
- Give tool-role entries only tool-result segments with a callId matching an assistant tool-call
- Build history through chatMessagesToProjectionEntries rather than hand-rolling projection entries
- Keep the assistant tool-call and its tool result together when trimming or editing history
- Validate callId correlation between tool-calls and tool-results before rendering
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
- Chat Completions does not support file URLs
- Chat file requires exactly one source
- Electron IPC is not available in this renderer context
- Tool messages require a correlated tool result
- Unsupported Chat content part
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)