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
- Drop incomplete tool_calls entries from the stored message before replay (filter out calls with null name or arguments)
- When persisting streamed assistant output, only save tool_calls after the stream finishes and both fields are complete
- 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
- Only persist assistant tool_calls after the stream completes with name and arguments populated
- Filter incomplete tool_calls when saving partial/streamed generations
- Check stored history for incomplete calls before resuming a conversation
- When truncating history mid-conversation, drop a trailing incomplete tool call
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
- Aliyun NLS returned a non-streaming result unexpectedly.
- [Hearing Pipeline] Stream input not supported
- [llm-streaming-control] handler failed
- [llm-streaming-control] signal handler failed
- No streaming TTS model selected and server returned no…
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)