fix: harden assistant authoring and UI

This commit is contained in:
ajnart authored and Thomas Camlong committed 2026-08-09 18:09:11 +02:00
1 parent fa0e55cc93
commit edc7b4d55b
51 files changed
+4118 -916

No files matched your search

@@ -3,6 +3,12 @@ import { describe, expect, test } from "vitest";
import { getAssistantStreamErrorMessage } from "./assistant-stream-error";
describe("assistant stream errors", () => {
test("explains malformed streamed tool input without implying the action ran", () => {
expect(getAssistantStreamErrorMessage(new Error("Error in input stream"))).toBe(
"The model produced incomplete tool input, so Homarr did not run the action. Try again; multiline custom-widget JSX will be sent as templateLines.",
);
});
test("explains invalid provider model identifiers", () => {
expect(
getAssistantStreamErrorMessage({
@@ -29,6 +29,9 @@ export const getAssistantStreamErrorMessage = (error: unknown) => {
const statusCode = getStatusCode(error);
const message = getErrorMessage(error);
if (/AI_InvalidToolInputError|Invalid input for tool|\bError in input stream\b/iu.test(message)) {
return "The model produced incomplete tool input, so Homarr did not run the action. Try again; multiline custom-widget JSX will be sent as templateLines.";
}
if (
/\bmodel(?:\s+id)?\b/iu.test(message) &&
/\b(invalid|unknown|unavailable|not found|not a valid)\b/iu.test(message)
@@ -27,4 +27,77 @@ describe("getSafeAssistantToolError", () => {
"The Homarr tool could not complete this request.",
);
});
test("preserves safe custom-widget validation paths and messages", () => {
const cause = Object.assign(new Error("validation failed"), {
issues: [
{ path: ["requests", "fixtures", "path"], message: "Required" },
{ path: ["template"], message: "Use Image instead of img (line 8, column 3)" },
],
});
const error = new TRPCError({ code: "BAD_REQUEST", cause });
expect(getSafeAssistantToolError(error, { toolName: "customWidget_create" })).toBe(
"The custom widget input was invalid: requests.fixtures.path: Required; template: Use Image instead of img (line 8, column 3)",
);
});
test("turns joined templateLines parser failures into actionable assistant and MCP text", () => {
const cause = Object.assign(new Error("validation failed"), {
issues: [{ path: ["template"], message: "Element 'img' is not supported; use Image instead" }],
});
const error = new TRPCError({
code: "BAD_REQUEST",
message: "The custom widget authoring input is invalid",
cause,
});
expect(getSafeAssistantToolError(error, { toolName: "customWidget_previewCreate" })).toBe(
"The custom widget input was invalid: template: Element 'img' is not supported; use Image instead",
);
});
test("does not expose custom-widget credentials in validation details", () => {
const error = new TRPCError({
code: "BAD_REQUEST",
message: "headers.Authorization: Bearer sk-secret-123456 at https://private.example/api",
});
const result = getSafeAssistantToolError(error, { toolName: "customWidget_validate" });
expect(result).toContain("[REDACTED]");
expect(result).toContain("[URL]");
expect(result).not.toContain("sk-secret-123456");
expect(result).not.toContain("private.example");
});
test("keeps non-custom-widget bad requests generic", () => {
expect(
getSafeAssistantToolError(new TRPCError({ code: "BAD_REQUEST", message: "sensitive validation" }), {
toolName: "integration_create",
}),
).toBe("The tool input was not valid.");
});
test.each([
["BAD_GATEWAY", "data source could not complete", "External request failed for https://private.example/api"],
["PAYLOAD_TOO_LARGE", "exceeded a safety limit", "Response exceeds the 1 MiB limit"],
["CONFLICT", "changed while this request was running", "Preview session changed; retry"],
] as const)("keeps custom-widget %s preview failures actionable and redacted", (code, expected, message) => {
const error = new TRPCError({ code, message });
const result = getSafeAssistantToolError(error, { toolName: "customWidget_previewQuery" });
expect(result).toContain(expected);
expect(result).not.toContain("private.example");
});
test("keeps unrelated gateway, payload, and conflict failures generic", () => {
for (const code of ["BAD_GATEWAY", "PAYLOAD_TOO_LARGE", "CONFLICT"] as const) {
expect(
getSafeAssistantToolError(new TRPCError({ code, message: "private upstream detail" }), {
toolName: "integration_create",
}),
).toBe("The Homarr tool could not complete this request.");
}
});
});
@@ -11,8 +11,48 @@ const getErrorChain = (error: unknown) => {
return chain;
};
export const getSafeAssistantToolError = (error: unknown) => {
const sanitizeValidationText = (value: string) =>
value
.replace(/https?:\/\/\S+/giu, "[URL]")
.replace(/\b(Bearer|Basic)\s+\S+/giu, "$1 [REDACTED]")
.replace(/\b(api[_ -]?key|authorization|password|token)\s*[:=]\s*\S+/giu, "$1=[REDACTED]")
.replace(/\s+/gu, " ")
.trim()
.slice(0, 240);
const getCustomWidgetValidationDetails = (chain: Record<string, unknown>[]) => {
const details = chain
.flatMap((candidate) => (Array.isArray(candidate.issues) ? candidate.issues : []))
.flatMap((issue) => {
const record = asErrorRecord(issue);
if (!record || typeof record.message !== "string") return [];
const path = Array.isArray(record.path)
? record.path
.filter((segment): segment is string | number => typeof segment === "string" || typeof segment === "number")
.map(String)
.join(".")
: "";
const message = sanitizeValidationText(record.message);
return message ? [`${path || "widget"}: ${message}`] : [];
})
.slice(0, 6);
if (details.length > 0) return details.join("; ");
const safeMessage = chain
.map((candidate) => (typeof candidate.message === "string" ? sanitizeValidationText(candidate.message) : ""))
.find(
(message) =>
message.length > 0 &&
!["BAD_REQUEST", "Invalid input", "Invalid request parameters"].includes(message) &&
!message.startsWith("[") &&
!message.startsWith("{"),
);
return safeMessage;
};
export const getSafeAssistantToolError = (error: unknown, options?: { toolName?: string }) => {
const chain = getErrorChain(error);
const isCustomWidgetTool = options?.toolName?.startsWith("customWidget_") === true;
if (
chain.some(
(candidate) =>
@@ -31,7 +71,35 @@ export const getSafeAssistantToolError = (error: unknown) => {
case "NOT_FOUND":
return "The requested resource was not found or is not compatible with this tool.";
case "BAD_REQUEST":
if (isCustomWidgetTool) {
const details = getCustomWidgetValidationDetails(chain);
if (details) return `The custom widget input was invalid: ${details}`;
}
return "The tool input was not valid.";
case "BAD_GATEWAY":
if (isCustomWidgetTool) {
const details = getCustomWidgetValidationDetails(chain);
return details
? `The custom widget data source could not complete the preview request: ${details}`
: "The custom widget data source could not complete the preview request.";
}
break;
case "PAYLOAD_TOO_LARGE":
if (isCustomWidgetTool) {
const details = getCustomWidgetValidationDetails(chain);
return details
? `The custom widget preview exceeded a safety limit: ${details}`
: "The custom widget preview exceeded a response or request size safety limit.";
}
break;
case "CONFLICT":
if (isCustomWidgetTool) {
const details = getCustomWidgetValidationDetails(chain);
return details
? `The custom widget preview changed while this request was running: ${details}`
: "The custom widget preview changed while this request was running. Retry the preview step.";
}
break;
case "PRECONDITION_FAILED":
return "The requested resource is not ready for this operation. Check its configuration and try again.";
case "TOO_MANY_REQUESTS":
@@ -0,0 +1,24 @@
import { describe, expect, test } from "vitest";
import { repairCustomWidgetToolInput } from "./assistant-tool-input-repair";
describe("repairCustomWidgetToolInput", () => {
test("repairs literal newlines generated inside a multiline Custom JSX template", () => {
const input = `{"definition":{"template":"<Stack>
<Text>{data.fixtures?.length} fixtures</Text>
</Stack>"}}`;
const repaired = repairCustomWidgetToolInput({ toolName: "customWidget_previewCreate", input });
expect(repaired).not.toBeNull();
expect(JSON.parse(repaired?.input ?? "")).toEqual({
definition: { template: "<Stack>\n <Text>{data.fixtures?.length} fixtures</Text>\n</Stack>" },
});
});
test("does not alter unrelated tools or guess at truncated JSON", () => {
expect(repairCustomWidgetToolInput({ toolName: "app_create", input: '{"name":"Wiki\npedia"}' })).toBeNull();
expect(
repairCustomWidgetToolInput({ toolName: "customWidget_create", input: '{"template":"<Text>broken' }),
).toBeNull();
});
});
@@ -0,0 +1,58 @@
interface AssistantToolCallInput {
toolName: string;
input: string;
}
const escapeControlCharactersInsideJsonStrings = (value: string) => {
let result = "";
let insideString = false;
let escaped = false;
for (const character of value) {
if (!insideString) {
result += character;
if (character === '"') insideString = true;
continue;
}
if (escaped) {
result += character;
escaped = false;
continue;
}
if (character === "\\") {
result += character;
escaped = true;
continue;
}
if (character === '"') {
result += character;
insideString = false;
continue;
}
switch (character) {
case "\n":
result += "\\n";
break;
case "\r":
result += "\\r";
break;
case "\t":
result += "\\t";
break;
default:
result += character;
}
}
return result;
};
export const repairCustomWidgetToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null => {
if (!toolCall.toolName.startsWith("customWidget_")) return null;
const repairedInput = escapeControlCharactersInsideJsonStrings(toolCall.input);
if (repairedInput === toolCall.input) return null;
try {
JSON.parse(repairedInput);
return { ...toolCall, input: repairedInput };
} catch {
return null;
}
};
@@ -1,7 +1,12 @@
import { describe, expect, test } from "vitest";
import type { UIMessage } from "ai";
import { getForcedAssistantToolName, withAssistantToolPolicy } from "./assistant-tool-policy";
import {
customWidgetAssistantInstructions,
getForcedAssistantToolName,
getRequiredAssistantToolNames,
withAssistantToolPolicy,
} from "./assistant-tool-policy";
const assistantMessage = (...parts: UIMessage["parts"]): UIMessage => ({
id: "assistant-message",
@@ -23,6 +28,27 @@ describe("withAssistantToolPolicy", () => {
});
});
describe("customWidgetAssistantInstructions", () => {
test("requires the complete skill and real preview-query data before creation", () => {
expect(customWidgetAssistantInstructions).toContain("first call customWidget_getSkill");
expect(customWidgetAssistantInstructions).toContain("customWidget_schema");
expect(customWidgetAssistantInstructions).toContain("customWidget_getComponentCatalog");
expect(customWidgetAssistantInstructions).toContain("customWidget_getComponent");
expect(customWidgetAssistantInstructions).toContain("customWidget_getSharedProps once");
expect(customWidgetAssistantInstructions).toContain("customWidget_getExample");
expect(customWidgetAssistantInstructions).toContain("templateLines");
expect(customWidgetAssistantInstructions).toContain("customWidget_previewQuery for every query");
expect(customWidgetAssistantInstructions).toContain("Call customWidget_create only after");
expect(customWidgetAssistantInstructions).toContain("managementPath");
});
test("routes meaningful follow-up choices through ask_user instead of prose", () => {
expect(customWidgetAssistantInstructions).toContain("call ask_user with explicit choices");
expect(customWidgetAssistantInstructions).toContain("Never end a custom-widget response with a prose question");
expect(customWidgetAssistantInstructions).toContain("purely rhetorical questions do not require ask_user");
});
});
describe("getForcedAssistantToolName", () => {
test("continues directly from the reviewed app form to app creation", () => {
expect(
@@ -152,3 +178,137 @@ describe("getForcedAssistantToolName", () => {
).toBeUndefined();
});
});
describe("getRequiredAssistantToolNames", () => {
test("requires a structured placement decision after custom widget creation", () => {
expect(
getRequiredAssistantToolNames([
assistantMessage({
type: "dynamic-tool",
toolName: "customWidget_create",
toolCallId: "create-widget-1",
input: { name: "PSG fixtures" },
state: "output-available",
output: {
id: "widget-1",
managementPath: "/manage/custom-widgets/edit/widget-1",
},
}),
]),
).toEqual(["configure_widget", "ask_user"]);
});
test("does not force a follow-up after failed custom widget creation", () => {
expect(
getRequiredAssistantToolNames([
assistantMessage({
type: "dynamic-tool",
toolName: "customWidget_create",
toolCallId: "create-widget-1",
input: { name: "PSG fixtures" },
state: "output-available",
output: { error: "The custom widget input was invalid." },
}),
]),
).toEqual([]);
});
test("requires the structured follow-up immediately after creation in the same agent run", () => {
const completedSteps = [
{
toolResults: [
{
toolName: "customWidget_previewQuery",
output: { status: 200, data: { events: [] } },
},
],
},
{
toolResults: [
{
toolName: "customWidget_create",
output: {
id: "widget-1",
managementPath: "/manage/custom-widgets/edit/widget-1",
nextAction: {
type: "place-custom-widget",
widgetKind: "customApi",
options: { definitionId: "widget-1" },
},
},
},
],
},
];
expect(getRequiredAssistantToolNames([], completedSteps)).toEqual(["configure_widget", "ask_user"]);
});
test("requires the structured follow-up after an approved creation executes before step zero", () => {
const responseMessages = [
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "create-widget-1",
toolName: "customWidget_create",
output: {
type: "json",
value: {
id: "widget-1",
managementPath: "/manage/custom-widgets/edit/widget-1",
nextAction: {
type: "place-custom-widget",
widgetKind: "customApi",
options: { definitionId: "widget-1" },
},
},
},
},
],
},
];
expect(getRequiredAssistantToolNames([], [], responseMessages)).toEqual(["configure_widget", "ask_user"]);
});
test("does not keep forcing the creation follow-up after a later step has completed", () => {
const completedSteps = [
{
toolResults: [
{
toolName: "customWidget_create",
output: {
id: "widget-1",
managementPath: "/manage/custom-widgets/edit/widget-1",
},
},
],
},
{
toolResults: [{ toolName: "board_all", output: { boards: [] } }],
},
];
expect(getRequiredAssistantToolNames([], completedSteps)).toEqual([]);
});
test("does not require a follow-up for an in-request creation error", () => {
expect(
getRequiredAssistantToolNames(
[],
[
{
toolResults: [
{
toolName: "customWidget_create",
output: { error: "The custom widget input was invalid." },
},
],
},
],
),
).toEqual([]);
});
});
@@ -4,6 +4,16 @@ import type { UIMessage } from "ai";
const mutationApprovalInstruction =
"This action is protected by Homarr's native approval UI. When the user's request and the required inputs are clear, call this tool immediately. The tool call only requests approval; it does not execute until the user selects Approve and run. Never ask for confirmation in prose before calling it.";
export const customWidgetAssistantInstructions = `
Custom Widget authoring rules:
- For every request to create, repair, validate, preview, or explain a Custom JSX widget, first call customWidget_getSkill. Its response includes the complete installed skill and all references in one lazy tool result. Follow that result as authoritative. Then call customWidget_schema and the compact customWidget_getComponentCatalog for the installed release. Fetch only the relevant named component documentation with customWidget_getComponent and, when its interaction pattern applies, one named example with customWidget_getExample before authoring. Component results contain only component-specific props; if the design needs shared props such as spacing or color, collect their names from the catalog and call customWidget_getSharedProps once with only those names.
- Use templateLines for multiline JSX tool input. Do not send a heavily escaped multiline template string when templateLines is accepted.
- Never skip the authoring lifecycle: customWidget_validate, repair all issues, customWidget_previewCreate, then customWidget_previewQuery for every query listed by the preview. Inspect the real status and returned data shape, correct request paths and template bindings, and repeat validation and preview after changes. Call customWidget_create only after the definition and its preview queries succeed.
- A preview session is not evidence that its API queries work. Never claim live data works until customWidget_previewQuery returned it. Use the returned previewPath when the user should inspect the rendered result.
- After creating a widget, include the returned managementPath as a Markdown link so the user can open and edit the exact widget in Homarr.
- If the user must choose what happens next, such as whether to add the created widget to a board or leave it unplaced, call ask_user with explicit choices. Never end a custom-widget response with a prose question that expects a typed answer. Open-ended conversation and purely rhetorical questions do not require ask_user; state optional next steps declaratively instead of asking about them.`;
export const withAssistantToolPolicy = (description: string | undefined, requiresApproval: boolean) => {
if (!requiresApproval) return description;
return description ? `${description}\n\n${mutationApprovalInstruction}` : mutationApprovalInstruction;
@@ -35,3 +45,106 @@ export const getForcedAssistantToolName = (messages: UIMessage[]) => {
? nextToolByHumanTool[toolName as keyof typeof nextToolByHumanTool]
: undefined;
};
type AssistantToolExecutionStep = {
toolResults: readonly {
toolName: string;
output: unknown;
}[];
};
type AssistantToolResponseMessage = {
role: string;
content: unknown;
};
const isSuccessfulCustomWidgetCreateOutput = (output: unknown) =>
typeof output === "object" &&
output !== null &&
"id" in output &&
typeof output.id === "string" &&
output.id.length > 0 &&
"managementPath" in output &&
typeof output.managementPath === "string" &&
output.managementPath.length > 0 &&
(!("error" in output) || output.error === undefined);
const getCustomWidgetCreateOutputFromResponseMessages = (responseMessages: readonly AssistantToolResponseMessage[]) => {
for (const message of responseMessages.toReversed()) {
if (message.role !== "tool" || !Array.isArray(message.content)) continue;
for (const part of message.content.toReversed()) {
if (
typeof part !== "object" ||
part === null ||
!("type" in part) ||
part.type !== "tool-result" ||
!("toolName" in part) ||
part.toolName !== "customWidget_create" ||
!("output" in part)
) {
continue;
}
const output = part.output;
if (
typeof output === "object" &&
output !== null &&
"type" in output &&
output.type === "json" &&
"value" in output
) {
return output.value;
}
return output;
}
}
return undefined;
};
export const getRequiredAssistantToolNames = (
messages: UIMessage[],
completedSteps: readonly AssistantToolExecutionStep[] = [],
responseMessages: readonly AssistantToolResponseMessage[] = [],
) => {
// prepareStep receives every completed server-side step in the current stream. Prefer its most
// recent result so a customWidget_create performed mid-run is handled before the model can fall
// back to a prose question. Once another step has completed, the creation is no longer the
// immediately preceding action and must not keep forcing the same follow-up.
if (completedSteps.length > 0) {
const latestCreateResult = completedSteps
.at(-1)
?.toolResults.toReversed()
.find((result) => result.toolName === "customWidget_create");
return latestCreateResult && isSuccessfulCustomWidgetCreateOutput(latestCreateResult.output)
? (["configure_widget", "ask_user"] as const)
: [];
}
// Approval-gated tools execute immediately before the first model step of the follow-up request.
// AI SDK exposes those fresh outputs through responseMessages, not completedSteps or the incoming
// UI history, so inspect them before falling back to the persisted UI part.
const responseCreateOutput = getCustomWidgetCreateOutputFromResponseMessages(responseMessages);
if (responseCreateOutput !== undefined) {
return isSuccessfulCustomWidgetCreateOutput(responseCreateOutput)
? (["configure_widget", "ask_user"] as const)
: [];
}
// Approval-gated tools finish in a later HTTP request. At step zero their result is already in
// the incoming UI history when the client has already received it.
const latestMessage = messages.at(-1);
if (latestMessage?.role !== "assistant") return [];
const latestToolPart = latestMessage.parts.toReversed().find((part) => isToolUIPart(part));
if (
latestToolPart === undefined ||
latestToolPart.state !== "output-available" ||
getToolName(latestToolPart) !== "customWidget_create"
) {
return [];
}
if (!isSuccessfulCustomWidgetCreateOutput(latestToolPart.output)) return [];
// A successful definition creation always has one structured follow-up: place the widget when
// a target is already known, or ask whether it should remain unplaced. Requiring one of these
// tools prevents the model from falling back to a prose "Want me to add it?" question.
return ["configure_widget", "ask_user"] as const;
};
+34 -12
View File
@@ -56,10 +56,21 @@ import { toProviderOptionsKey } from "./assistant-provider-options";
import { assistantExecutionPolicy } from "./assistant-execution-policy";
import { getAssistantStreamErrorMessage } from "./assistant-stream-error";
import { getSafeAssistantToolError } from "./assistant-tool-error";
import { getForcedAssistantToolName, withAssistantToolPolicy } from "./assistant-tool-policy";
import { repairCustomWidgetToolInput } from "./assistant-tool-input-repair";
import {
customWidgetAssistantInstructions,
getForcedAssistantToolName,
getRequiredAssistantToolNames,
withAssistantToolPolicy,
} from "./assistant-tool-policy";
export const maxDuration = 300;
// Five 1 MB image attachments expand to roughly 6.7 MB as base64 before the surrounding message
// history and JSON envelope are added. Keep the transport ceiling above the composer contract while
// retaining a bounded request size for this authenticated endpoint.
const assistantRequestMaxBytes = 12_000_000;
const logger = createLogger({ module: "assistant" });
const getToolApprovalSecret = () =>
Buffer.from(
@@ -117,7 +128,7 @@ Homarr concepts:
Action rules:
- Prefer read-only tools before actions.
- When required information is missing or the user must choose between meaningful alternatives, call ask_user. Do not ask the question only in prose. Offer distinct choices, categorize agreement or proceeding as affirmative, refusal as negative, and unrelated selections as alternative, then leave the freeform Other answer enabled. A confirmation question must have exactly one affirmative option.
- Whenever you need an answer from the user before choosing the next action, call ask_user. Never end a prose response with a question that expects the user to type a reply. Offer distinct choices, categorize agreement or proceeding as affirmative, refusal as negative, and unrelated selections as alternative, then leave the freeform Other answer enabled. A confirmation question must have exactly one affirmative option. Purely rhetorical questions and open-ended conversation that does not block an action may remain prose, but optional next steps should be stated declaratively instead of phrased as a question.
- Mutating Homarr tools already pause for native user approval. Calling a mutation only proposes the action; it cannot execute until the user selects Approve and run. Once the requested change is sufficiently specified, call the mutation immediately so the approval UI appears.
- A prose response such as "Please confirm if you would like me to proceed", "Would you like me to proceed?", or a parameter summary that asks for confirmation is incorrect. Never ask for a second textual confirmation before an approval-gated tool call.
- Do not retry a denied action.
@@ -359,7 +370,7 @@ const createDemoAssistantResponse = (request: z.infer<typeof requestSchema>) =>
export async function POST(request: Request) {
const contentLength = Number(request.headers.get("content-length") ?? 0);
if (contentLength > 2_000_000) {
if (contentLength > assistantRequestMaxBytes) {
return Response.json({ error: "The assistant request is too large." }, { status: 413 });
}
@@ -369,7 +380,7 @@ export async function POST(request: Request) {
}
const requestBody = await request.text();
if (requestBody.length > 2_000_000) {
if (requestBody.length > assistantRequestMaxBytes) {
return Response.json({ error: "The assistant request is too large." }, { status: 413 });
}
const parsed = requestSchema.safeParse(
@@ -493,7 +504,7 @@ export async function POST(request: Request) {
toolName: mcpTool.name,
error: error instanceof Error ? error.message : String(error),
});
return { error: getSafeAssistantToolError(error) };
return { error: getSafeAssistantToolError(error, { toolName: mcpTool.name }) };
}
},
}),
@@ -562,15 +573,25 @@ export async function POST(request: Request) {
let hasUpstreamCost = false;
const result = streamText({
model: provider(modelId),
instructions: `${assistantInstructions}${openRouterServerToolsEnabled ? webSearchInstructions : ""}${mentionContext}`,
instructions: `${assistantInstructions}${customWidgetAssistantInstructions}${openRouterServerToolsEnabled ? webSearchInstructions : ""}${mentionContext}`,
messages: await convertToModelMessages(prepareMessagesForModel(parsed.data.messages as UIMessage[])),
tools: availableTools,
prepareStep: ({ stepNumber }) => {
if (stepNumber !== 0 || forcedToolName === undefined || !(forcedToolName in availableTools)) return undefined;
return {
activeTools: [forcedToolName],
toolChoice: { type: "tool", toolName: forcedToolName },
};
prepareStep: ({ stepNumber, steps, responseMessages }) => {
if (stepNumber === 0 && forcedToolName !== undefined && forcedToolName in availableTools) {
return {
activeTools: [forcedToolName],
toolChoice: { type: "tool", toolName: forcedToolName },
};
}
const requiredToolNames = getRequiredAssistantToolNames(
parsed.data.messages as UIMessage[],
steps,
responseMessages,
).filter((toolName) => toolName in availableTools);
if (requiredToolNames.length > 0) {
return { activeTools: requiredToolNames, toolChoice: "required" };
}
return undefined;
},
stopWhen: stepCountIs(assistantExecutionPolicy.maxSteps),
abortSignal: request.signal,
@@ -581,6 +602,7 @@ export async function POST(request: Request) {
},
maxOutputTokens: assistantExecutionPolicy.maxOutputTokens,
maxRetries: 2,
experimental_repairToolCall: ({ toolCall }) => Promise.resolve(repairCustomWidgetToolInput(toolCall)),
reasoning: parsed.data.reasoning === "auto" ? undefined : parsed.data.reasoning,
providerOptions:
configuration.provider === "openrouter" || openRouterServerToolsEnabled