fix: bound preview query context
This commit is contained in:
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,
|
||||
|
||||
Reference in new issue
Block a user