{"record":{"id":"a651eb38664fb500","repo":"moeru-ai/airi","slug":"tool-messages-require-a-correlated-tool-result","errorCode":null,"errorMessage":"Tool messages require a correlated tool result","messagePattern":"Tool messages require a correlated tool result","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/core-agent/src/messages/chat-completions.ts","lineNumber":105,"sourceCode":"\nfunction renderEntry(message: ProjectionEntry, supportsContentArray: boolean): ChatMessage[] {\n  const result: ChatMessage[] = []\n  const role = message.role === 'context' || message.role === 'event' || message.role === 'summary' ? 'user' : message.role\n  let parts: CommonContentPart[] = []\n  let assistantParts: Array<{ type: 'text', text: string } | { type: 'refusal', refusal: string }> = []\n  let calls: NonNullable<Extract<ChatMessage, { role: 'assistant' }>['tool_calls']> = []\n  function flush() {\n    if (role === 'assistant') {\n      if (assistantParts.length || calls.length) {\n        const content = !supportsContentArray || assistantParts.every(part => part.type === 'text')\n          ? assistantParts.map(part => part.type === 'text' ? part.text : part.refusal).join('')\n          : assistantParts\n        result.push({ role, content, ...(calls.length ? { tool_calls: calls } : {}) })\n      }\n    }\n    else if (parts.length) {\n      if (role === 'tool')\n        throw new Error('Tool messages require a correlated tool result')\n      if (role === 'system' || role === 'developer') {\n        if (parts.some(part => part.type !== 'text'))\n          throw new Error(`${role} messages require text content`)\n        result.push({ role, content: parts.map(part => part.type === 'text' ? part.text : '').join('') })\n      }\n      else {\n        const content = !supportsContentArray || parts.every(part => part.type === 'text')\n          ? parts.map(part => part.type === 'text' ? part.text : '').join('')\n          : parts\n        result.push({ role, content })\n      }\n    }\n    parts = []\n    assistantParts = []\n    calls = []\n  }\n  for (const segment of message.segments) {\n    if (segment.type === 'tool-result') {","sourceCodeStart":87,"sourceCodeEnd":123,"githubUrl":"https://github.com/moeru-ai/airi/blob/438a067dde47aa0bdb46c2323d1fe293dc805218/packages/core-agent/src/messages/chat-completions.ts#L87-L123","documentation":"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.","triggerScenarios":"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'.","commonSituations":"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.","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"],"exampleFix":"// before\n{ role: 'tool', segments: [{ type: 'text', text: 'result: ok' }] }\n// after\n{ role: 'tool', segments: [{ type: 'tool-result', callId: 'call_123', content: [{ type: 'text', text: 'result: ok' }] }] }","handlingStrategy":"validation","validationCode":"function toolEntryIsValid(entry) {\n  if (entry.role !== 'tool') return true\n  return entry.segments.every(s => s.type === 'tool-result' && typeof s.callId === 'string')\n}\nif (!entries.every(toolEntryIsValid)) throw new Error('Tool entries must contain only correlated tool-result segments')","typeGuard":"function isToolResultSegment(seg) {\n  return seg.type === 'tool-result' && typeof seg.callId === 'string' && seg.callId.length > 0\n}\nfunction isWellFormedToolEntry(entry) {\n  return entry.role !== 'tool' || entry.segments.length > 0 && entry.segments.every(isToolResultSegment)\n}","tryCatchPattern":"try {\n  return conversationToChatMessages(conversation, supportsContentArray)\n} catch (err) {\n  if (err.message === 'Tool messages require a correlated tool result') {\n    console.error('Malformed tool entry in conversation:', JSON.stringify(conversation.turns, null, 2))\n  }\n  throw err\n}","preventionTips":["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"],"tags":["tool-messages","correlation","renderer","chat-completions"],"backgroundTag":"invalid-argument-value","analyzedSha":"438a067dde47aa0bdb46c2323d1fe293dc805218","analyzedAt":"2026-09-17T01:14:42.644Z","contentChangedAt":"2026-09-17T01:14:42.644Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}