fix: bound preview query context

This commit is contained in:
Thomas Camlong committed 2026-08-14 22:46:15 +02:00
1 parent 28e58a5a5c
commit 168091134d
6 files changed
+139 -5

No files matched your search

@@ -74,7 +74,7 @@ These interfaces do not return an archive URL or archive hash.
4. Build one definition, using `templateLines` for multiline JSX tool input.
5. Validate the definition without saving it and repair every reported issue.
6. Create a preview session.
7. Run `customWidget_previewQuery` for every query returned by the preview and inspect its actual status and data shape. Use
7. Run `customWidget_previewQuery` for every query returned by the preview and inspect its actual status and data shape. Assistant conversations retain a bounded response preview so large APIs cannot exhaust the model context. Use
`{}` for load queries; manual queries require exactly the returned primitive `parameterNames`.
8. Test relevant actions in simulation mode.
9. Open the returned preview path with browser tools.
@@ -45,4 +45,41 @@ describe("convertAssistantMessagesToModelMessages", () => {
expect(serialized).toContain('"name":"Home"');
expect(result.at(-1)).toEqual({ role: "user", content: [{ type: "text", text: "Continue." }] });
});
test("compacts oversized custom widget preview query results from conversation history", async () => {
const oversizedData = Array.from({ length: 1_000 }, (_, index) => ({
id: index,
title: `Result ${index}`,
description: "x".repeat(100),
}));
const messages: UIMessage[] = [
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "dynamic-tool",
toolName: "customWidget_previewQuery",
toolCallId: "preview-query-1",
state: "output-available",
input: { sessionId: "preview-1", requestId: "results", params: {} },
output: { ok: true, status: 200, data: oversizedData },
},
],
},
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Continue building the widget." }],
},
];
const result = await convertAssistantMessagesToModelMessages(messages);
const serialized = JSON.stringify(result);
expect(serialized).toContain('"truncated":true');
expect(serialized).toContain('"status":200');
expect(serialized).toContain("Continue building the widget.");
expect(serialized.length).toBeLessThan(10_000);
});
});
@@ -1,8 +1,32 @@
import { convertToModelMessages } from "ai";
import type { UIMessage } from "ai";
import { customWidgetPreviewQueryOutputMaxCharacters, toAssistantToolOutput } from "./assistant-tool-output";
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const compactPreviewQueryOutputs = (messages: UIMessage[]): UIMessage[] =>
messages.map((message) => ({
...message,
parts: message.parts.map((part) => {
if (!isRecord(part) || typeof part.type !== "string") return part;
const record: Record<string, unknown> = part;
const toolName =
part.type === "dynamic-tool" ? record.toolName : part.type.startsWith("tool-") ? part.type.slice(5) : undefined;
if (toolName !== "customWidget_previewQuery" || record.state !== "output-available") return part;
return {
...part,
output: toAssistantToolOutput(record.output, {
maxCharacters: customWidgetPreviewQueryOutputMaxCharacters,
}),
} as unknown as typeof part;
}),
}));
export const convertAssistantMessagesToModelMessages = (messages: UIMessage[]) =>
convertToModelMessages(messages, {
convertToModelMessages(compactPreviewQueryOutputs(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,
@@ -0,0 +1,29 @@
import { describe, expect, test } from "vitest";
import { customWidgetPreviewQueryOutputMaxCharacters, toAssistantToolOutput } from "./assistant-tool-output";
describe("assistant tool output", () => {
test("preserves tool output within the configured context budget", () => {
expect(toAssistantToolOutput({ ok: true, data: [{ id: 1 }] }, { maxCharacters: 8_000 })).toEqual({
ok: true,
data: [{ id: 1 }],
});
});
test("returns a bounded preview for oversized tool output", () => {
const output = toAssistantToolOutput(
{ ok: true, status: 200, data: Array.from({ length: 1_000 }, (_, index) => ({ index, value: "x".repeat(100) })) },
{ maxCharacters: customWidgetPreviewQueryOutputMaxCharacters },
);
const serialized = JSON.stringify(output);
expect(output).toEqual(
expect.objectContaining({
truncated: true,
originalCharacters: expect.any(Number),
preview: expect.stringContaining('"status":200'),
}),
);
expect(serialized.length).toBeLessThanOrEqual(customWidgetPreviewQueryOutputMaxCharacters);
});
});
@@ -1,9 +1,48 @@
import { serialize } from "superjson";
export const customWidgetPreviewQueryOutputMaxCharacters = 8_000;
type AssistantToolOutputOptions = {
maxCharacters?: number;
};
/**
* 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;
export const toAssistantToolOutput = (value: unknown, options: AssistantToolOutputOptions = {}) => {
const output = serialize(value as Parameters<typeof serialize>[0]).json;
if (options.maxCharacters === undefined) return output;
const serialized = JSON.stringify(output);
if (serialized === undefined || serialized.length <= options.maxCharacters) return output;
const outputRecord =
typeof output === "object" && output !== null && !Array.isArray(output)
? (output as Record<string, unknown>)
: undefined;
const metadata = outputRecord
? Object.fromEntries(
["ok", "status", "statusText", "error"].flatMap((key) =>
outputRecord[key] === undefined ? [] : [[key, outputRecord[key]]],
),
)
: {};
const createPreview = (previewCharacters: number) => ({
...metadata,
truncated: true as const,
originalCharacters: serialized.length,
preview: serialized.slice(0, previewCharacters),
note: "The preview query response was truncated to protect the conversation context. The preview contains the beginning of the real response for shape and binding verification.",
});
let lowerBound = 0;
let upperBound = serialized.length;
while (lowerBound < upperBound) {
const candidate = Math.ceil((lowerBound + upperBound) / 2);
if (JSON.stringify(createPreview(candidate)).length <= options.maxCharacters) lowerBound = candidate;
else upperBound = candidate - 1;
}
return createPreview(lowerBound);
};
@@ -50,7 +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 { customWidgetPreviewQueryOutputMaxCharacters, toAssistantToolOutput } from "./assistant-tool-output";
import {
createCustomWidgetComponentDocumentBudget,
createCustomWidgetDynamicContextController,
@@ -537,7 +537,12 @@ 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 toAssistantToolOutput(result);
return toAssistantToolOutput(
result,
mcpTool.name === "customWidget_previewQuery"
? { maxCharacters: customWidgetPreviewQueryOutputMaxCharacters }
: undefined,
);
} catch (error) {
logger.error("Assistant tool call failed", {
toolName: mcpTool.name,