moeru-ai/airi · error · Error

Cannot replay an unfinished function call

Error message

Cannot replay an unfinished function call

What it means

When replaying stored assistant messages, each tool call must have both function.name and function.arguments to be re-emitted as a portable tool-call segment. Streaming or interrupted generations can persist tool_calls entries with null fields; replaying those would produce an invalid call, so the library throws.

Solutions

  1. Drop incomplete tool_calls entries from the stored message before replay (filter out calls with null name or arguments)
  2. When persisting streamed assistant output, only save tool_calls after the stream finishes and both fields are complete
  3. If the call was never executed, remove it from history rather than replaying it; if it was executed, reconstruct name/arguments from the tool result record

Example fix

// before
messages.push(assistantMessage) // assistantMessage.tool_calls[0].function.arguments === null
// after
assistantMessage.tool_calls = assistantMessage.tool_calls?.filter(c => c.function.name != null && c.function.arguments != null)
messages.push(assistantMessage)
Defensive patterns

Strategy: validation

Validate before calling

for (const m of messages) {
  if (m.role === 'assistant' && m.tool_calls?.some(c => c.function.name == null || c.function.arguments == null)) {
    throw new Error('History contains an unfinished tool call; drop or complete it before replay')
  }
}

Type guard

function isReplayableToolCall(call) {
  return call.function.name != null && call.function.arguments != null
}
const replayable = messages.every(m => m.role !== 'assistant' || (m.tool_calls ?? []).every(isReplayableToolCall))

Try / catch

try {
  const entries = chatMessagesToProjectionEntries(messages)
} catch (err) {
  if (err.message === 'Cannot replay an unfinished function call') {
    const repaired = messages.map(m => m.role === 'assistant'
      ? { ...m, tool_calls: m.tool_calls?.filter(isReplayableToolCall) }
      : m)
    return chatMessagesToProjectionEntries(repaired)
  }
  throw err
}

Prevention

When it happens

Trigger: Calling chatMessagesToProjectionEntries (via chatMessagesToTurns or generation) on a message history whose assistant message contains a tool_calls entry where call.function.name == null or call.function.arguments == null — typically a tool call captured mid-stream or truncated at the end of a generation.

Common situations: Persisting assistant output from a streamed response that was cut off before the tool call completed; saving partial chunks when a request errored or timed out; a provider returning an empty arguments string field omitted entirely; resuming a conversation after a crash mid-generation.

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

Appendix: source

Thrown at packages/core-agent/src/messages/chat-completions.ts:60

 * chatMessagesToProjectionEntries([{ role: 'user', content: 'Hello' }])[0].segments
 * // => [{ type: 'text', text: 'Hello' }]
 */
export function chatMessagesToProjectionEntries(messages: (ChatMessage | { role: 'error', content: string })[], idPrefix = 'message'): ProjectionEntry[] {
  return messages.map((message, index) => {
    const id = `${idPrefix}-${index}`
    if (message.role === 'error')
      return { id, role: 'user', segments: [{ type: 'text', text: `User encountered error: ${message.content}` }] }
    if (message.role === 'tool')
      return { id, role: 'tool', segments: [{ type: 'tool-result', callId: message.tool_call_id, content: chatContentToInputSegments(message.content) }] }
    if (message.role === 'assistant') {
      const segments: Extract<ProjectionEntry, { role: 'assistant' }>['segments'] = typeof message.content === 'string'
        ? [{ type: 'text', text: message.content }]
        : message.content?.map(part => part.type === 'text' ? { type: 'text', text: part.text } : { type: 'refusal', text: part.refusal }) ?? []
      if (message.refusal)
        segments.push({ type: 'refusal', text: message.refusal })
      for (const call of message.tool_calls ?? []) {
        if (call.function.name == null || call.function.arguments == null)
          throw new Error('Cannot replay an unfinished function call')
        segments.push({ type: 'tool-call', callId: call.id, name: call.function.name, arguments: call.function.arguments })
      }
      return { id, role: 'assistant', segments }
    }
    if (message.role === 'system' || message.role === 'developer')
      return { id, role: message.role, segments: typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : message.content.map(part => ({ type: 'text', text: part.text })) }
    return { id, role: message.role, segments: chatContentToInputSegments(message.content) }
  })
}

/** Reads persisted Chat records; the storage identity scopes turn, round, and invocation ids. */
export function chatMessagesToTurns(messages: Parameters<typeof chatMessagesToProjectionEntries>[0], idPrefix?: string): Turn[] {
  return readTurns(chatMessagesToProjectionEntries(messages, idPrefix))
}

function writeContent(segment: MessageSegment): CommonContentPart {
  switch (segment.type) {
    case 'image': return { type: 'image_url', image_url: { url: segment.url, detail: segment.detail } }

View on GitHub (pinned to 438a067dde)