fix assistant tool result streaming

This commit is contained in:
ajnart committed 2026-08-13 19:57:02 +02:00
1 parent 557b3f8d5f
commit 237b6cb119
6 files changed
+141 -12

No files matched your search

+3
View File
@@ -212,6 +212,9 @@ multi-item dashboard changes normally finish without requiring a follow-up “Co
Homarr tools sequentially to prevent routed models from interleaving or truncating parallel JSON arguments, while
every mutation still pauses for its normal approval. A safely recoverable truncated icon-search object is repaired
before validation; incomplete search strings and all mutation inputs are never guessed.
Homarr also normalizes internal tool results to the model's JSON-compatible message format before continuing an
agent loop. Rich server values such as dates cannot invalidate the next model step, and an interrupted tool call
without a result is omitted when the conversation resumes instead of making the entire prompt fail.
The assistant can create a formatted dashboard note by adding a **Notebook** widget. Ask it for a note with
headings, lists, tasks, links, emphasis, tables, or images; it generates compatible rich-text content, shows
@@ -0,0 +1,48 @@
import { describe, expect, test } from "vitest";
import type { UIMessage } from "ai";
import { convertAssistantMessagesToModelMessages } from "./assistant-message-conversion";
describe("convertAssistantMessagesToModelMessages", () => {
test("drops an interrupted tool call while preserving completed tool results", async () => {
const messages: UIMessage[] = [
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Find Homarr icons." }],
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "tool-icon_findIcons",
toolCallId: "cancelled-call",
state: "input-available",
input: { searchText: "homarr" },
},
{
type: "tool-board_getBoardSettings",
toolCallId: "completed-call",
state: "output-available",
input: { id: "board-1" },
output: { id: "board-1", name: "Home" },
},
],
},
{
id: "user-2",
role: "user",
parts: [{ type: "text", text: "Continue." }],
},
];
const result = await convertAssistantMessagesToModelMessages(messages);
const serialized = JSON.stringify(result);
expect(serialized).not.toContain("cancelled-call");
expect(serialized).toContain("completed-call");
expect(serialized).toContain('"name":"Home"');
expect(result.at(-1)).toEqual({ role: "user", content: [{ type: "text", text: "Continue." }] });
});
});
@@ -0,0 +1,9 @@
import { convertToModelMessages } from "ai";
import type { UIMessage } from "ai";
export const convertAssistantMessagesToModelMessages = (messages: UIMessage[]) =>
convertToModelMessages(messages, {
// An aborted response can leave a streamed tool call in conversation history without a
// result. Replaying it makes several OpenAI-compatible providers reject the whole prompt.
ignoreIncompleteToolCalls: true,
});
@@ -3,6 +3,7 @@ import { jsonSchema, simulateReadableStream, stepCountIs, streamText, tool } fro
import { MockLanguageModelV4 } from "ai/test";
import { repairAssistantToolInput } from "./assistant-tool-input-repair";
import { toAssistantToolOutput } from "./assistant-tool-output";
const usage = {
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
@@ -167,4 +168,68 @@ describe("Assistant tool loop", () => {
expect.objectContaining({ toolCallId: "icon-call" }),
);
});
test("normalizes rich tRPC results before the next model step", async () => {
const streamErrors = vi.fn();
const inspect = vi.fn(() =>
toAssistantToolOutput({
updatedAt: new Date("2026-08-10T00:00:00.000Z"),
optional: undefined,
exactCount: 28_154n,
groups: new Map([["icons", new Set(["homarr.svg", "homarr.png"])]]),
}),
);
const model = new MockLanguageModelV4({
doStream: [
{
stream: simulateReadableStream({
chunks: [
{ type: "stream-start" as const, warnings: [] },
{
type: "tool-call" as const,
toolCallId: "inspect-call",
toolName: "inspect",
input: "{}",
},
finish("tool-calls"),
],
chunkDelayInMs: null,
}),
},
{
stream: simulateReadableStream({
chunks: [
{ type: "stream-start" as const, warnings: [] },
{ type: "text-start" as const, id: "answer" },
{ type: "text-delta" as const, id: "answer", delta: "The rich result is valid." },
{ type: "text-end" as const, id: "answer" },
finish("stop"),
],
chunkDelayInMs: null,
}),
},
],
});
const result = streamText({
model,
prompt: "Inspect rich Homarr data.",
tools: {
inspect: tool({
inputSchema: jsonSchema({ type: "object", properties: {}, additionalProperties: false }),
execute: inspect,
}),
},
stopWhen: stepCountIs(2),
onError: streamErrors,
});
await expect(result.text).resolves.toBe("The rich result is valid.");
expect(streamErrors).not.toHaveBeenCalled();
expect(inspect).toHaveBeenCalledTimes(1);
const nextPrompt = JSON.stringify(model.doStreamCalls[1]?.prompt);
expect(nextPrompt).toContain('"updatedAt":"2026-08-10T00:00:00.000Z"');
expect(nextPrompt).toContain('"optional":null');
expect(nextPrompt).toContain('"exactCount":"28154"');
expect(nextPrompt).toContain('"groups":[["icons",["homarr.svg","homarr.png"]]]');
});
});
@@ -0,0 +1,9 @@
import { serialize } from "superjson";
/**
* AI SDK model messages only accept JSON-compatible tool results. Homarr's tRPC callers can
* return richer values such as Date, bigint, Map, Set, NaN, and nested undefined values.
* SuperJSON's transport representation normalizes those values without restoring their runtime
* types, keeping subsequent agent steps valid while preserving the useful data.
*/
export const toAssistantToolOutput = (value: unknown) => serialize(value as Parameters<typeof serialize>[0]).json;
@@ -3,16 +3,7 @@ import { hkdfSync } from "node:crypto";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { MetadataExtractor } from "@ai-sdk/openai-compatible";
import type { ToolSet, UIMessage } from "ai";
import {
convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
jsonSchema,
stepCountIs,
streamText,
tool,
} from "ai";
import { parse, stringify } from "superjson";
import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepCountIs, streamText, tool } from "ai";
import { z } from "zod/v4";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
@@ -46,6 +37,7 @@ import type {
import { extractMcpTools } from "../../mcp/_extract-tools";
import { getRequestedMentionIds, sanitizeAttachmentFilename } from "./assistant-chat-input";
import { getAssistantModelLookupStatus } from "./assistant-model-lookup";
import { convertAssistantMessagesToModelMessages } from "./assistant-message-conversion";
import {
getOpenRouterWebSearchRequests,
getOpenRouterWebSearchSources,
@@ -58,6 +50,7 @@ import { assistantExecutionPolicy } from "./assistant-execution-policy";
import { getAssistantStreamErrorMessage } from "./assistant-stream-error";
import { getSafeAssistantToolError } from "./assistant-tool-error";
import { repairAssistantToolInput } from "./assistant-tool-input-repair";
import { toAssistantToolOutput } from "./assistant-tool-output";
import {
createCustomWidgetDynamicContextController,
getCustomWidgetAuthoringContext,
@@ -509,7 +502,7 @@ export async function POST(request: Request) {
const result = await (procedure as (value: unknown) => Promise<unknown>)(
input && Object.keys(input as object).length > 0 ? input : undefined,
);
return parse(stringify(result));
return toAssistantToolOutput(result);
} catch (error) {
logger.error("Assistant tool call failed", {
toolName: mcpTool.name,
@@ -587,7 +580,9 @@ export async function POST(request: Request) {
let hasReportedCost = false;
let upstreamCost = 0;
let hasUpstreamCost = false;
const initialModelMessages = await convertToModelMessages(prepareMessagesForModel(incomingMessages));
const initialModelMessages = await convertAssistantMessagesToModelMessages(
prepareMessagesForModel(incomingMessages),
);
const modelMessages =
customWidgetAuthoringContext.systemContext.length > 0
? prunePreloadedCustomWidgetModelMessages(initialModelMessages)