diff --git a/apps/docs/docs/management/custom-widgets/agent-authoring.mdx b/apps/docs/docs/management/custom-widgets/agent-authoring.mdx index c59930f64..8d9380157 100644 --- a/apps/docs/docs/management/custom-widgets/agent-authoring.mdx +++ b/apps/docs/docs/management/custom-widgets/agent-authoring.mdx @@ -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. diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-message-conversion.spec.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-message-conversion.spec.ts index b1231f6d1..b87083ea5 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-message-conversion.spec.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-message-conversion.spec.ts @@ -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); + }); }); diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-message-conversion.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-message-conversion.ts index 8043fd0c3..41760a86d 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-message-conversion.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-message-conversion.ts @@ -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 => + 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 = 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, diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-output.spec.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-output.spec.ts new file mode 100644 index 000000000..826fdbcaf --- /dev/null +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-output.spec.ts @@ -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); + }); +}); diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-output.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-output.ts index 4ee6fc9e8..6f67787ab 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-tool-output.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-tool-output.ts @@ -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[0]).json; +export const toAssistantToolOutput = (value: unknown, options: AssistantToolOutputOptions = {}) => { + const output = serialize(value as Parameters[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) + : 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); +}; diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index c8fc3c4b9..024e89cab 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -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)( 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,