fix assistant tool result streaming
This commit is contained in:
1 parent
557b3f8d5f
commit
237b6cb119
6 files changed
+141
-12
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user