fix assistant exports and tool calls
This commit is contained in:
1 parent
9c64f8772d
commit
443fbe0c36
13 files changed
+728
-29
No files matched your search
@@ -4,6 +4,7 @@ import {
|
||||
getOpenRouterWebSearchRequests,
|
||||
getOpenRouterWebSearchSources,
|
||||
normalizeOpenRouterWebSearchSources,
|
||||
withOpenRouterToolRequestOptions,
|
||||
withOpenRouterWebSearch,
|
||||
} from "./assistant-openrouter";
|
||||
|
||||
@@ -28,6 +29,21 @@ describe("withOpenRouterWebSearch", () => {
|
||||
expect(withOpenRouterWebSearch(body)).toBe(body);
|
||||
});
|
||||
|
||||
test("runs OpenRouter function tools sequentially while preserving optional server web search", () => {
|
||||
expect(
|
||||
withOpenRouterToolRequestOptions(
|
||||
{
|
||||
model: "deepseek/deepseek-v4-flash-latest",
|
||||
tools: [{ type: "function", function: { name: "icon_findIcons", parameters: {} } }],
|
||||
},
|
||||
{ webSearchEnabled: true },
|
||||
),
|
||||
).toMatchObject({
|
||||
parallel_tool_calls: false,
|
||||
tools: [{ type: "function", function: { name: "icon_findIcons" } }, { type: "openrouter:web_search" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("reads OpenRouter server-tool usage from a response", () => {
|
||||
expect(getOpenRouterWebSearchRequests({ usage: { server_tool_use: { web_search_requests: 2 } } })).toBe(2);
|
||||
expect(getOpenRouterWebSearchRequests({ usage: {} })).toBeUndefined();
|
||||
|
||||
@@ -82,6 +82,17 @@ export const withOpenRouterWebSearch = (body: Record<string, unknown>) => {
|
||||
return { ...body, tools: [...tools, openRouterWebSearchTool] };
|
||||
};
|
||||
|
||||
export const withOpenRouterToolRequestOptions = (
|
||||
body: Record<string, unknown>,
|
||||
options: { webSearchEnabled: boolean },
|
||||
) => ({
|
||||
...(options.webSearchEnabled ? withOpenRouterWebSearch(body) : body),
|
||||
// OpenRouter enables parallel tool calls for most models by default. Several routed models
|
||||
// interleave or truncate function argument streams when they emit multiple calls together.
|
||||
// Sequential calls preserve the same agent loop while making every tool input independently valid.
|
||||
parallel_tool_calls: false,
|
||||
});
|
||||
|
||||
export const getOpenRouterWebSearchRequests = (value: unknown) => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
||||
const usage = "usage" in value && typeof value.usage === "object" && value.usage !== null ? value.usage : undefined;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { repairCustomWidgetToolInput } from "./assistant-tool-input-repair";
|
||||
import { repairAssistantToolInput } from "./assistant-tool-input-repair";
|
||||
|
||||
describe("repairCustomWidgetToolInput", () => {
|
||||
describe("repairAssistantToolInput", () => {
|
||||
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 });
|
||||
const repaired = repairAssistantToolInput({ toolName: "customWidget_previewCreate", input });
|
||||
|
||||
expect(repaired).not.toBeNull();
|
||||
expect(JSON.parse(repaired?.input ?? "")).toEqual({
|
||||
@@ -16,7 +16,7 @@ describe("repairCustomWidgetToolInput", () => {
|
||||
});
|
||||
|
||||
test("repairs every JSON-forbidden control character inside a Custom Widget string", () => {
|
||||
const repaired = repairCustomWidgetToolInput({
|
||||
const repaired = repairAssistantToolInput({
|
||||
toolName: "customWidget_validate",
|
||||
input: '{"value":"before\u0000\b\fafter"}',
|
||||
});
|
||||
@@ -25,12 +25,28 @@ describe("repairCustomWidgetToolInput", () => {
|
||||
});
|
||||
|
||||
test("does not alter unrelated tools or guess at truncated JSON", () => {
|
||||
expect(repairCustomWidgetToolInput({ toolName: "app_create", input: '{"name":"Wiki\npedia"}' })).toBeNull();
|
||||
expect(repairAssistantToolInput({ toolName: "app_create", input: '{"name":"Wiki\npedia"}' })).toBeNull();
|
||||
expect(
|
||||
repairCustomWidgetToolInput({ toolName: "customWidget_create", input: '{"template":"<Text>broken' }),
|
||||
repairAssistantToolInput({ toolName: "customWidget_create", input: '{"template":"<Text>broken' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("recovers a truncated read-only icon search after the model omits the closing object delimiter", () => {
|
||||
expect(
|
||||
repairAssistantToolInput({
|
||||
toolName: "icon_findIcons",
|
||||
input: '{"searchText":"homarr","limitPerGroup":6',
|
||||
}),
|
||||
).toEqual({
|
||||
toolName: "icon_findIcons",
|
||||
input: '{"searchText":"homarr","limitPerGroup":6}',
|
||||
});
|
||||
});
|
||||
|
||||
test("does not guess icon search text when the streamed string itself is incomplete", () => {
|
||||
expect(repairAssistantToolInput({ toolName: "icon_findIcons", input: '{"searchText":"homar' })).toBeNull();
|
||||
});
|
||||
|
||||
test.each([
|
||||
"customWidget_getSkill",
|
||||
"customWidget_schema",
|
||||
@@ -38,10 +54,10 @@ describe("repairCustomWidgetToolInput", () => {
|
||||
"customWidget_getComponentCatalog",
|
||||
"customWidget_list",
|
||||
])("normalizes malformed or provider-specific no-input arguments for %s", (toolName) => {
|
||||
expect(repairCustomWidgetToolInput({ toolName, input: "null" })?.input).toBe("{}");
|
||||
expect(repairCustomWidgetToolInput({ toolName, input: "[]" })?.input).toBe("{}");
|
||||
expect(repairCustomWidgetToolInput({ toolName, input: '{"unexpected":true}' })?.input).toBe("{}");
|
||||
expect(repairCustomWidgetToolInput({ toolName, input: '"' })?.input).toBe("{}");
|
||||
expect(repairCustomWidgetToolInput({ toolName, input: "{}" })).toBeNull();
|
||||
expect(repairAssistantToolInput({ toolName, input: "null" })?.input).toBe("{}");
|
||||
expect(repairAssistantToolInput({ toolName, input: "[]" })?.input).toBe("{}");
|
||||
expect(repairAssistantToolInput({ toolName, input: '{"unexpected":true}' })?.input).toBe("{}");
|
||||
expect(repairAssistantToolInput({ toolName, input: '"' })?.input).toBe("{}");
|
||||
expect(repairAssistantToolInput({ toolName, input: "{}" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,36 @@ const escapeControlCharactersInsideJsonStrings = (value: string) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
export const repairCustomWidgetToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null => {
|
||||
const repairIconSearchInput = <T extends AssistantToolCallInput>(toolCall: T): T | null => {
|
||||
if (toolCall.toolName !== "icon_findIcons") return null;
|
||||
|
||||
// Some OpenAI-compatible providers finish a streamed tool call after the last value but before
|
||||
// the closing object delimiter. Recover only the harmless, read-only icon search fields instead
|
||||
// of trying to balance arbitrary mutation input.
|
||||
const searchTextMatch = /"searchText"\s*:\s*("(?:\\.|[^"\\])*")/u.exec(toolCall.input);
|
||||
if (!searchTextMatch?.[1]) return null;
|
||||
let searchText: unknown;
|
||||
try {
|
||||
searchText = JSON.parse(searchTextMatch[1]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof searchText !== "string") return null;
|
||||
|
||||
const limitMatch = /"limitPerGroup"\s*:\s*(\d+)/u.exec(toolCall.input);
|
||||
const limitPerGroup = limitMatch?.[1] ? Number(limitMatch[1]) : undefined;
|
||||
const hasValidLimit =
|
||||
limitPerGroup !== undefined && Number.isInteger(limitPerGroup) && limitPerGroup >= 1 && limitPerGroup <= 500;
|
||||
return {
|
||||
...toolCall,
|
||||
input: JSON.stringify({
|
||||
searchText,
|
||||
...(hasValidLimit ? { limitPerGroup } : {}),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const repairCustomWidgetToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null => {
|
||||
if (!toolCall.toolName.startsWith("customWidget_")) return null;
|
||||
if (customWidgetNoInputToolNames.has(toolCall.toolName)) {
|
||||
try {
|
||||
@@ -83,3 +112,6 @@ export const repairCustomWidgetToolInput = <T extends AssistantToolCallInput>(to
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const repairAssistantToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null =>
|
||||
repairIconSearchInput(toolCall) ?? repairCustomWidgetToolInput(toolCall);
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, test, vi } from "vitest";
|
||||
import { jsonSchema, simulateReadableStream, stepCountIs, streamText, tool } from "ai";
|
||||
import { MockLanguageModelV4 } from "ai/test";
|
||||
|
||||
import { repairAssistantToolInput } from "./assistant-tool-input-repair";
|
||||
|
||||
const usage = {
|
||||
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
|
||||
outputTokens: { total: 5, text: 5, reasoning: 0 },
|
||||
@@ -100,4 +102,69 @@ describe("Assistant tool loop", () => {
|
||||
expect(nextPrompt).toContain("homarr.svg");
|
||||
expect(nextPrompt).toContain('"id":"board-1"');
|
||||
});
|
||||
|
||||
test("repairs and executes an icon search whose streamed object is missing its closing delimiter", async () => {
|
||||
const findIcons = vi.fn(() => ({
|
||||
countIcons: 1,
|
||||
icons: [
|
||||
{
|
||||
slug: "homarr-labs/dashboard-icons",
|
||||
icons: [{ name: "homarr.svg", url: "https://cdn.example.com/homarr.svg" }],
|
||||
},
|
||||
],
|
||||
}));
|
||||
const model = new MockLanguageModelV4({
|
||||
doStream: [
|
||||
{
|
||||
stream: simulateReadableStream({
|
||||
chunks: [
|
||||
{ type: "stream-start" as const, warnings: [] },
|
||||
{
|
||||
type: "tool-call" as const,
|
||||
toolCallId: "icon-call",
|
||||
toolName: "icon_findIcons",
|
||||
input: '{"searchText":"homarr"',
|
||||
},
|
||||
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 icon search completed." },
|
||||
{ type: "text-end" as const, id: "answer" },
|
||||
finish("stop"),
|
||||
],
|
||||
chunkDelayInMs: null,
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = streamText({
|
||||
model,
|
||||
prompt: "Find the Homarr icon.",
|
||||
tools: {
|
||||
icon_findIcons: tool({
|
||||
inputSchema: jsonSchema({
|
||||
type: "object",
|
||||
properties: { searchText: { type: "string" } },
|
||||
additionalProperties: false,
|
||||
}),
|
||||
execute: findIcons,
|
||||
}),
|
||||
},
|
||||
experimental_repairToolCall: ({ toolCall }) => Promise.resolve(repairAssistantToolInput(toolCall)),
|
||||
stopWhen: stepCountIs(2),
|
||||
});
|
||||
|
||||
await expect(result.text).resolves.toBe("The icon search completed.");
|
||||
expect(findIcons).toHaveBeenCalledWith(
|
||||
{ searchText: "homarr" },
|
||||
expect.objectContaining({ toolCallId: "icon-call" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources
|
||||
import { getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { createCustomWidgetDynamicContextController } from "./custom-widget-authoring-context";
|
||||
import { repairCustomWidgetToolInput } from "./assistant-tool-input-repair";
|
||||
import { repairAssistantToolInput } from "./assistant-tool-input-repair";
|
||||
|
||||
const usage = {
|
||||
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
|
||||
@@ -217,7 +217,7 @@ describe("Custom Widget assistant tool loop", () => {
|
||||
execute,
|
||||
}),
|
||||
},
|
||||
experimental_repairToolCall: ({ toolCall }) => Promise.resolve(repairCustomWidgetToolInput(toolCall)),
|
||||
experimental_repairToolCall: ({ toolCall }) => Promise.resolve(repairAssistantToolInput(toolCall)),
|
||||
stopWhen: stepCountIs(2),
|
||||
});
|
||||
|
||||
|
||||
@@ -50,13 +50,14 @@ import {
|
||||
getOpenRouterWebSearchRequests,
|
||||
getOpenRouterWebSearchSources,
|
||||
normalizeOpenRouterWebSearchSources,
|
||||
withOpenRouterToolRequestOptions,
|
||||
withOpenRouterWebSearch,
|
||||
} from "./assistant-openrouter";
|
||||
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 { repairCustomWidgetToolInput } from "./assistant-tool-input-repair";
|
||||
import { repairAssistantToolInput } from "./assistant-tool-input-repair";
|
||||
import {
|
||||
createCustomWidgetDynamicContextController,
|
||||
getCustomWidgetAuthoringContext,
|
||||
@@ -143,7 +144,7 @@ Action rules:
|
||||
- To create a formatted note on a dashboard, call configure_widget with kind "notebook". Put valid Tiptap-compatible HTML in options.content and set options.showToolbar and options.allowReadOnlyCheck when useful. Use semantic paragraphs, headings, lists, task lists, blockquotes, tables, links, emphasis, and images as appropriate. Never include scripts, styles, iframes, event handlers, or unsafe URL protocols.
|
||||
- When choosing an app icon without configure_app, call the Homarr icon findIcons tool first and use one of its returned local icon URLs. Never invent a third-party icon CDN URL.
|
||||
- When a Homarr tool returns a usable image or icon URL and a visual summary helps, embed that exact URL with Markdown image syntax. App summary tables must use an Icon column such as . Never replace an available returned icon with emoji, and never invent or transform image URLs.
|
||||
- Complete batch requests in the same run. Use parallel independent read-only calls when supported, reuse results, continue calling tools until every requested item has been attempted, and summarize only after the batch is complete. Do not stop after a partial batch or ask the user to type "Continue".
|
||||
- Complete batch requests in the same run. Perform independent read-only calls in sequence, reuse results, continue calling tools until every requested item has been attempted, and summarize only after the batch is complete. Do not stop after a partial batch or ask the user to type "Continue".
|
||||
- Browser tools can navigate within Homarr, open existing Homarr UI, or refresh the active view after a completed change. Never navigate to an arbitrary external URL and never refresh before a mutation finishes.
|
||||
- Keep responses concise and lead with the result. Summarize tool output instead of dumping JSON.
|
||||
- Use well-formed GitHub-flavored Markdown. Put each list item on its own line and leave a blank line before lists.
|
||||
@@ -571,7 +572,12 @@ export async function POST(request: Request) {
|
||||
apiKey: providerApiKey,
|
||||
headers: providerHeaders,
|
||||
includeUsage: true,
|
||||
transformRequestBody: openRouterServerToolsEnabled ? withOpenRouterWebSearch : undefined,
|
||||
transformRequestBody:
|
||||
configuration.provider === "openrouter"
|
||||
? (body) => withOpenRouterToolRequestOptions(body, { webSearchEnabled: openRouterServerToolsEnabled })
|
||||
: openRouterServerToolsEnabled
|
||||
? withOpenRouterWebSearch
|
||||
: undefined,
|
||||
metadataExtractor: createProviderTelemetryExtractor(),
|
||||
});
|
||||
|
||||
@@ -624,7 +630,7 @@ export async function POST(request: Request) {
|
||||
},
|
||||
maxOutputTokens: assistantExecutionPolicy.maxOutputTokens,
|
||||
maxRetries: 2,
|
||||
experimental_repairToolCall: ({ toolCall }) => Promise.resolve(repairCustomWidgetToolInput(toolCall)),
|
||||
experimental_repairToolCall: ({ toolCall }) => Promise.resolve(repairAssistantToolInput(toolCall)),
|
||||
reasoning: parsed.data.reasoning === "auto" ? undefined : parsed.data.reasoning,
|
||||
providerOptions:
|
||||
configuration.provider === "openrouter" || openRouterServerToolsEnabled
|
||||
|
||||
Reference in new issue
Block a user