fix assistant automatic approvals
This commit is contained in:
1 parent
8f81de775e
commit
9e0a6a758f
16 files changed
+403
-78
No files matched your search
@@ -135,13 +135,17 @@ Common collection and widget results are summarized inline as compact responsive
|
||||
structured result remains available in the tool card details.
|
||||
|
||||
The shield control in the conversation header can enable automatic approvals for the current conversation.
|
||||
While enabled, Homarr approves protected mutation calls as they arrive. Human questions and editable review
|
||||
forms still wait for input. Automatic approvals turn off when you switch conversations, and the shared
|
||||
approval tracker prevents the same call from being approved twice when both a board widget and the floating
|
||||
assistant are visible.
|
||||
While enabled, Homarr accepts every affirmative approval step as it arrives: protected mutations are approved,
|
||||
presented app details and board changes are used, and structured questions with an explicitly categorized
|
||||
**Confirm** answer choose that answer. Neutral choices such as selecting a board still wait for input because
|
||||
they do not have a safe affirmative default. Automatic approvals turn off when you switch conversations, and
|
||||
the shared approval tracker prevents the same call from being handled twice when both a board widget and the
|
||||
floating assistant are visible.
|
||||
|
||||
When the assistant genuinely needs missing information or a choice, it can pause with a structured
|
||||
question. Users can select one of the proposed answers or choose **Other** and enter their own response.
|
||||
question. Each proposed answer is visibly categorized as **Confirm**, **Decline**, or **Option**, so the effect
|
||||
of automatic approval is unambiguous. Users can select one of the proposed answers or choose **Other** and
|
||||
enter their own response.
|
||||
Submitting the answer resumes the same agent run automatically. The minimized activity bar shows the
|
||||
question or approval that needs attention and opens directly back to the request. While an answer, app
|
||||
form, or mutation approval is pending, the composer preserves its draft but does not replace the pending
|
||||
|
||||
@@ -45,7 +45,7 @@ active conversation silently: select **Open conversation** inside the widget bef
|
||||
- Mention permitted apps, integrations, boards, widgets, and tools with `@`.
|
||||
- Use `/` shortcuts for common dashboard tasks.
|
||||
- Review tool calls, approve mutations, and answer structured assistant questions inline.
|
||||
- Enable conversation-scoped automatic approvals from the shield control when you want protected mutations to run without individual approval clicks.
|
||||
- Enable conversation-scoped automatic approvals from the shield control to approve protected mutations, use presented form details, and choose answers categorized as affirmative without individual clicks.
|
||||
- Edit user messages and copy, regenerate, branch, quote, export, or rate responses.
|
||||
- Inspect Markdown, sources, generated files, tool results, reasoning, token use, context-window use, timing,
|
||||
speed, provider routing, and cost.
|
||||
|
||||
@@ -94,7 +94,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 and leave the freeform Other answer enabled.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
getAssistantAffirmativeOption,
|
||||
getAssistantAffirmativeResult,
|
||||
getAssistantAskUserOptionKind,
|
||||
} from "./assistant-ask-user";
|
||||
|
||||
describe("assistant structured choice categories", () => {
|
||||
test("uses the explicit category instead of button order or wording", () => {
|
||||
const options = [
|
||||
{ id: "home", label: "Home", kind: "alternative" as const },
|
||||
{ id: "continue", label: "Use the proposed board", kind: "affirmative" as const },
|
||||
{ id: "cancel", label: "Keep the current board", kind: "negative" as const },
|
||||
];
|
||||
|
||||
expect(getAssistantAffirmativeOption(options)).toEqual(options[1]);
|
||||
expect(getAssistantAffirmativeResult(options)).toEqual({
|
||||
answer: "Use the proposed board",
|
||||
optionId: "continue",
|
||||
optionKind: "affirmative",
|
||||
source: "option",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not guess among neutral selections", () => {
|
||||
expect(
|
||||
getAssistantAffirmativeOption([
|
||||
{ id: "home", label: "Home", kind: "alternative" },
|
||||
{ id: "media", label: "Media", kind: "alternative" },
|
||||
]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("recognizes affirmative and negative legacy choices", () => {
|
||||
expect(getAssistantAskUserOptionKind({ id: "yes", label: "Yes, create it" })).toBe("affirmative");
|
||||
expect(getAssistantAskUserOptionKind({ id: "no", label: "No, keep it" })).toBe("negative");
|
||||
expect(getAssistantAskUserOptionKind({ id: "home", label: "Home" })).toBe("alternative");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { AskUserArgs, AskUserResult, AssistantAskUserOptionKind } from "./assistant-tool-contracts";
|
||||
|
||||
export type AssistantAskUserOption = AskUserArgs["options"][number];
|
||||
|
||||
const affirmativeTokens = new Set(["accept", "agree", "allow", "approve", "confirm", "continue", "proceed", "yes"]);
|
||||
const negativeTokens = new Set(["cancel", "decline", "deny", "disagree", "no", "reject", "stop"]);
|
||||
|
||||
const inferOptionKind = (option: Pick<AssistantAskUserOption, "id" | "label">): AssistantAskUserOptionKind => {
|
||||
const tokens = `${option.id} ${option.label}`
|
||||
.toLocaleLowerCase("en")
|
||||
.split(/[^a-z]+/u)
|
||||
.filter(Boolean);
|
||||
if (tokens.some((token) => affirmativeTokens.has(token))) return "affirmative";
|
||||
if (tokens.some((token) => negativeTokens.has(token))) return "negative";
|
||||
return "alternative";
|
||||
};
|
||||
|
||||
export const getAssistantAskUserOptionKind = (
|
||||
option: Pick<AssistantAskUserOption, "id" | "label"> & { kind?: AssistantAskUserOptionKind },
|
||||
) => option.kind ?? inferOptionKind(option);
|
||||
|
||||
export const getAssistantAffirmativeOption = (options: readonly AssistantAskUserOption[]) =>
|
||||
options.find((option) => getAssistantAskUserOptionKind(option) === "affirmative");
|
||||
|
||||
export const getAssistantAffirmativeResult = (
|
||||
options: readonly AssistantAskUserOption[],
|
||||
): AskUserResult | undefined => {
|
||||
const option = getAssistantAffirmativeOption(options);
|
||||
if (!option) return undefined;
|
||||
|
||||
return {
|
||||
answer: option.label,
|
||||
optionId: option.id,
|
||||
optionKind: "affirmative",
|
||||
source: "option",
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement } from "react";
|
||||
import { act, createElement, Fragment } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
AssistantAutoApprovalProvider,
|
||||
createAssistantAutoApprovalTracker,
|
||||
useAssistantAutoApproval,
|
||||
useAssistantAutomaticAction,
|
||||
} from "./assistant-auto-approval";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type AutoApproval = ReturnType<typeof useAssistantAutoApproval>;
|
||||
let currentAutoApproval: AutoApproval | null = null;
|
||||
let automaticActionInProgress = false;
|
||||
const containers: HTMLDivElement[] = [];
|
||||
|
||||
const AutoApprovalProbe = () => {
|
||||
@@ -21,8 +23,27 @@ const AutoApprovalProbe = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const AutomaticActionProbe = ({
|
||||
ready,
|
||||
completed,
|
||||
confirm,
|
||||
}: {
|
||||
ready: boolean;
|
||||
completed: boolean;
|
||||
confirm: () => void;
|
||||
}) => {
|
||||
automaticActionInProgress = useAssistantAutomaticAction({
|
||||
toolCallId: "configure-app-1",
|
||||
ready,
|
||||
completed,
|
||||
confirm,
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
currentAutoApproval = null;
|
||||
automaticActionInProgress = false;
|
||||
for (const container of containers.splice(0)) container.remove();
|
||||
});
|
||||
|
||||
@@ -87,12 +108,12 @@ describe("assistant automatic approval tracker", () => {
|
||||
});
|
||||
act(() => currentAutoApproval?.setEnabled(true));
|
||||
|
||||
expect(currentAutoApproval?.requestApproval("app-create-1", () => undefined)).toBe(true);
|
||||
expect(currentAutoApproval?.requestApproval("app-create-1", () => undefined)).toBe(false);
|
||||
expect(currentAutoApproval?.requestAction("app-create-1", () => undefined)).toBe(true);
|
||||
expect(currentAutoApproval?.requestAction("app-create-1", () => undefined)).toBe(false);
|
||||
|
||||
act(() => currentAutoApproval?.setEnabled(false));
|
||||
act(() => currentAutoApproval?.setEnabled(true));
|
||||
expect(currentAutoApproval?.requestApproval("app-create-1", () => undefined)).toBe(true);
|
||||
expect(currentAutoApproval?.requestAction("app-create-1", () => undefined)).toBe(true);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
@@ -115,11 +136,52 @@ describe("assistant automatic approval tracker", () => {
|
||||
act(() => currentAutoApproval?.setEnabled(true));
|
||||
|
||||
expect(
|
||||
currentAutoApproval?.requestApproval("app-create-1", () => {
|
||||
currentAutoApproval?.requestAction("app-create-1", () => {
|
||||
throw new Error("Approval transport failed");
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(currentAutoApproval?.requestApproval("app-create-1", () => undefined)).toBe(true);
|
||||
expect(currentAutoApproval?.requestAction("app-create-1", () => undefined)).toBe(true);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
test("confirms a ready human-tool action exactly once and clears progress on completion", async () => {
|
||||
const container = document.createElement("div");
|
||||
containers.push(container);
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
let confirmationCount = 0;
|
||||
const confirm = () => {
|
||||
confirmationCount += 1;
|
||||
};
|
||||
const renderAction = async (ready: boolean, completed: boolean) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
AssistantAutoApprovalProvider,
|
||||
{ conversationId: "local-thread-1" },
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(AutoApprovalProbe),
|
||||
createElement(AutomaticActionProbe, { ready, completed, confirm }),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
await renderAction(false, false);
|
||||
act(() => currentAutoApproval?.setEnabled(true));
|
||||
await renderAction(true, false);
|
||||
expect(confirmationCount).toBe(1);
|
||||
expect(automaticActionInProgress).toBe(true);
|
||||
|
||||
await renderAction(true, false);
|
||||
expect(confirmationCount).toBe(1);
|
||||
|
||||
await renderAction(false, true);
|
||||
expect(automaticActionInProgress).toBe(false);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ export const createAssistantAutoApprovalTracker = () => {
|
||||
interface AssistantAutoApprovalContextValue {
|
||||
enabled: boolean;
|
||||
setEnabled: (enabled: boolean) => void;
|
||||
requestApproval: (toolCallId: string, approve: () => void) => boolean;
|
||||
requestAction: (toolCallId: string, confirm: () => void) => boolean;
|
||||
}
|
||||
|
||||
const AssistantAutoApprovalContext = createContext<AssistantAutoApprovalContextValue | null>(null);
|
||||
@@ -51,12 +51,12 @@ export const AssistantAutoApprovalProvider = ({
|
||||
setEnabledState(false);
|
||||
}, [conversationId]);
|
||||
|
||||
const requestApproval = useCallback(
|
||||
(toolCallId: string, approve: () => void) => {
|
||||
const requestAction = useCallback(
|
||||
(toolCallId: string, confirm: () => void) => {
|
||||
if (!enabled || !trackerRef.current.claim(toolCallId)) return false;
|
||||
|
||||
try {
|
||||
approve();
|
||||
confirm();
|
||||
return true;
|
||||
} catch {
|
||||
trackerRef.current.release(toolCallId);
|
||||
@@ -66,7 +66,7 @@ export const AssistantAutoApprovalProvider = ({
|
||||
[enabled],
|
||||
);
|
||||
|
||||
const value = useMemo(() => ({ enabled, setEnabled, requestApproval }), [enabled, requestApproval, setEnabled]);
|
||||
const value = useMemo(() => ({ enabled, setEnabled, requestAction }), [enabled, requestAction, setEnabled]);
|
||||
|
||||
return <AssistantAutoApprovalContext.Provider value={value}>{children}</AssistantAutoApprovalContext.Provider>;
|
||||
};
|
||||
@@ -76,3 +76,32 @@ export const useAssistantAutoApproval = () => {
|
||||
if (value === null) throw new Error("useAssistantAutoApproval must be used within AssistantAutoApprovalProvider");
|
||||
return value;
|
||||
};
|
||||
|
||||
export const useAssistantAutomaticAction = ({
|
||||
toolCallId,
|
||||
ready,
|
||||
completed,
|
||||
confirm,
|
||||
}: {
|
||||
toolCallId: string;
|
||||
ready: boolean;
|
||||
completed: boolean;
|
||||
confirm: () => void;
|
||||
}) => {
|
||||
const { enabled, requestAction } = useAssistantAutoApproval();
|
||||
const [inProgress, setInProgress] = useState(false);
|
||||
const confirmRef = useRef(confirm);
|
||||
confirmRef.current = confirm;
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || completed || !enabled) {
|
||||
setInProgress(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const requested = requestAction(toolCallId, () => confirmRef.current());
|
||||
setInProgress(requested);
|
||||
}, [completed, enabled, ready, requestAction, toolCallId]);
|
||||
|
||||
return inProgress;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Group, Loader, Text } from "@mantine/core";
|
||||
|
||||
import classes from "./assistant-panel.module.css";
|
||||
|
||||
export const AssistantAutomaticActionProgress = ({ label }: { label: string }) => (
|
||||
<Group component="output" className={classes.autoApprovalProgress} gap="sm" wrap="nowrap">
|
||||
<Loader type="bars" size="sm" color="green" />
|
||||
<Text size="sm" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
@@ -44,10 +44,13 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||
|
||||
import {
|
||||
getAssistantBoardSettingsResult,
|
||||
getAssistantBoardSettingsDefaultTab,
|
||||
getChangedBoardSettings,
|
||||
getCustomCssWarnings,
|
||||
} from "./assistant-board-settings";
|
||||
import { useAssistantAutomaticAction } from "./assistant-auto-approval";
|
||||
import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress";
|
||||
import { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status";
|
||||
import { AssistantHumanToolError } from "./assistant-human-tools";
|
||||
import classes from "./assistant-panel.module.css";
|
||||
@@ -92,6 +95,7 @@ export const AssistantConfigureBoardSettingsTool = ({
|
||||
result,
|
||||
addResult,
|
||||
status,
|
||||
toolCallId,
|
||||
}: ToolCallMessagePartProps<ConfigureBoardSettingsArgs, ConfigureBoardSettingsResult>) => {
|
||||
const t = useScopedI18n("common.assistant.configureBoardSettings");
|
||||
const hasCompleteArguments = hasCompleteAssistantToolArguments(status);
|
||||
@@ -100,6 +104,17 @@ export const AssistantConfigureBoardSettingsTool = ({
|
||||
{ id: boardId },
|
||||
{ enabled: result === undefined && hasCompleteArguments && boardId.length > 0, retry: false },
|
||||
);
|
||||
const autoConfirming = useAssistantAutomaticAction({
|
||||
toolCallId,
|
||||
ready: result === undefined && settings.data !== undefined && hasCompleteArguments,
|
||||
completed: result !== undefined,
|
||||
confirm: () => {
|
||||
if (!settings.data || !args) return;
|
||||
const currentValues = toCurrentValues(settings.data);
|
||||
const proposedValues = normalizeFormValues(settings.data, args.changes);
|
||||
addResult(getAssistantBoardSettingsResult(settings.data.id, currentValues, proposedValues));
|
||||
},
|
||||
});
|
||||
|
||||
if (result) {
|
||||
const cancelled = "cancelled" in result && result.cancelled;
|
||||
@@ -144,6 +159,14 @@ export const AssistantConfigureBoardSettingsTool = ({
|
||||
|
||||
if (!settings.data) return <BoardSettingsSkeleton label={t("loading")} />;
|
||||
|
||||
if (autoConfirming) {
|
||||
return (
|
||||
<Box className={classes.boardSettingsTool}>
|
||||
<AssistantAutomaticActionProgress label={t("automaticContinue")} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BoardSettingsForm
|
||||
current={settings.data}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
getAssistantBoardSettingsResult,
|
||||
getAssistantBoardSettingsDefaultTab,
|
||||
getChangedBoardSettings,
|
||||
getCustomCssWarnings,
|
||||
@@ -21,6 +22,20 @@ describe("getChangedBoardSettings", () => {
|
||||
customCss: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("builds the exact result used by automatic board approval", () => {
|
||||
expect(
|
||||
getAssistantBoardSettingsResult(
|
||||
"board-1",
|
||||
{ pageTitle: "Home", primaryColor: "#112233" },
|
||||
{ pageTitle: "Home", primaryColor: "#445566" },
|
||||
),
|
||||
).toEqual({ id: "board-1", primaryColor: "#445566" });
|
||||
expect(getAssistantBoardSettingsResult("board-1", { pageTitle: "Home" }, { pageTitle: "Home" })).toEqual({
|
||||
id: "board-1",
|
||||
cancelled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCustomCssWarnings", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AssistantBoardSettingsChanges } from "./assistant-tool-contracts";
|
||||
import type { AssistantBoardSettingsChanges, ConfigureBoardSettingsResult } from "./assistant-tool-contracts";
|
||||
|
||||
export const assistantBoardSettingKeys = [
|
||||
"pageTitle",
|
||||
@@ -27,6 +27,15 @@ export const getChangedBoardSettings = (current: AssistantBoardSettingsChanges,
|
||||
),
|
||||
) as AssistantBoardSettingsChanges;
|
||||
|
||||
export const getAssistantBoardSettingsResult = (
|
||||
id: string,
|
||||
current: AssistantBoardSettingsChanges,
|
||||
next: AssistantBoardSettingsChanges,
|
||||
): ConfigureBoardSettingsResult => {
|
||||
const changes = getChangedBoardSettings(current, next);
|
||||
return Object.keys(changes).length === 0 ? { id, cancelled: true } : { id, ...changes };
|
||||
};
|
||||
|
||||
export const getCustomCssWarnings = (css: string) => ({
|
||||
importsStylesheet: /@import\b/iu.test(css),
|
||||
loadsRemoteResource: /url\(\s*["']?https?:\/\//iu.test(css),
|
||||
|
||||
@@ -2,14 +2,24 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ToolCallMessagePartProps, ToolCallMessagePartStatus } from "@assistant-ui/react";
|
||||
import { Alert, Box, Button, Group, Skeleton, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconCheck, IconMessageQuestion, IconPencil, IconX } from "@tabler/icons-react";
|
||||
import { Alert, Badge, Box, Button, Group, Skeleton, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowRight,
|
||||
IconCheck,
|
||||
IconMessageQuestion,
|
||||
IconPencil,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { AppForm } from "@homarr/forms-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { appManageSchema } from "@homarr/validation/app";
|
||||
|
||||
import { getAssistantAffirmativeResult, getAssistantAskUserOptionKind } from "./assistant-ask-user";
|
||||
import { useAssistantAutomaticAction } from "./assistant-auto-approval";
|
||||
import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress";
|
||||
import classes from "./assistant-panel.module.css";
|
||||
import { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status";
|
||||
import { normalizeAssistantAppIconUrl } from "./assistant-tool-contracts";
|
||||
@@ -44,6 +54,7 @@ export const AssistantAskUserTool = ({
|
||||
result,
|
||||
addResult,
|
||||
status,
|
||||
toolCallId,
|
||||
}: ToolCallMessagePartProps<AskUserArgs, AskUserResult>) => {
|
||||
const t = useScopedI18n("common.assistant.askUser");
|
||||
const [showOther, setShowOther] = useState(false);
|
||||
@@ -51,6 +62,18 @@ export const AssistantAskUserTool = ({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const otherInputRef = useRef<HTMLInputElement>(null);
|
||||
const options = Array.isArray(args?.options) ? args.options : [];
|
||||
const affirmativeResult = getAssistantAffirmativeResult(options);
|
||||
const hasValidQuestion = hasCompleteAssistantToolArguments(status) && Boolean(args?.question) && options.length >= 2;
|
||||
const autoConfirming = useAssistantAutomaticAction({
|
||||
toolCallId,
|
||||
ready: result === undefined && hasValidQuestion && affirmativeResult !== undefined,
|
||||
completed: result !== undefined,
|
||||
confirm: () => {
|
||||
if (!affirmativeResult) return;
|
||||
setSubmitting(true);
|
||||
addResult(affirmativeResult);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (showOther) otherInputRef.current?.focus();
|
||||
@@ -99,7 +122,7 @@ export const AssistantAskUserTool = ({
|
||||
const submitOther = () => {
|
||||
const answer = other.trim();
|
||||
if (!answer) return;
|
||||
submitResult({ answer, source: "other" });
|
||||
submitResult({ answer, optionKind: "alternative", source: "other" });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -121,31 +144,58 @@ export const AssistantAskUserTool = ({
|
||||
</Group>
|
||||
|
||||
<Stack gap="xs" mt="md">
|
||||
{options.map((option, index) => (
|
||||
<Button
|
||||
key={`${option.id}:${index}`}
|
||||
className={classes.humanToolOption}
|
||||
variant="default"
|
||||
size="md"
|
||||
fullWidth
|
||||
justify="space-between"
|
||||
disabled={submitting}
|
||||
onClick={() => submitResult({ answer: option.label, optionId: option.id, source: "option" })}
|
||||
>
|
||||
<Box ta="start">
|
||||
<Text component="span" size="sm" fw={650}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option.description && (
|
||||
<Text component="span" display="block" size="xs" c="dimmed" fw={400}>
|
||||
{option.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Button>
|
||||
))}
|
||||
{autoConfirming ? (
|
||||
<AssistantAutomaticActionProgress label={t("automaticAffirmative")} />
|
||||
) : (
|
||||
options.map((option, index) => {
|
||||
const kind = getAssistantAskUserOptionKind(option);
|
||||
const icon =
|
||||
kind === "affirmative" ? (
|
||||
<IconCheck size={17} />
|
||||
) : kind === "negative" ? (
|
||||
<IconX size={17} />
|
||||
) : (
|
||||
<IconArrowRight size={17} />
|
||||
);
|
||||
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
|
||||
|
||||
{args.allowOther !== false &&
|
||||
return (
|
||||
<Button
|
||||
key={`${option.id}:${index}`}
|
||||
className={classes.humanToolOption}
|
||||
variant={kind === "alternative" ? "default" : "light"}
|
||||
color={color}
|
||||
size="md"
|
||||
fullWidth
|
||||
justify="space-between"
|
||||
leftSection={icon}
|
||||
rightSection={
|
||||
<Badge variant="light" color={color} size="xs">
|
||||
{t(`optionKind.${kind}`)}
|
||||
</Badge>
|
||||
}
|
||||
disabled={submitting}
|
||||
onClick={() =>
|
||||
submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" })
|
||||
}
|
||||
>
|
||||
<Box ta="start">
|
||||
<Text component="span" size="sm" fw={650}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option.description && (
|
||||
<Text component="span" display="block" size="xs" c="dimmed" fw={400}>
|
||||
{option.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{!autoConfirming &&
|
||||
args.allowOther !== false &&
|
||||
(showOther ? (
|
||||
<Group align="flex-end" gap="xs" wrap="nowrap">
|
||||
<TextInput
|
||||
@@ -185,6 +235,11 @@ export const AssistantAskUserTool = ({
|
||||
size="md"
|
||||
fullWidth
|
||||
leftSection={<IconPencil size={17} />}
|
||||
rightSection={
|
||||
<Badge variant="light" color="gray" size="xs">
|
||||
{t("optionKind.custom")}
|
||||
</Badge>
|
||||
}
|
||||
justify="flex-start"
|
||||
onClick={() => setShowOther(true)}
|
||||
>
|
||||
@@ -206,6 +261,16 @@ export const AssistantConfigureAppTool = ({
|
||||
const t = useScopedI18n("common.assistant.configureApp");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const initialValues = getAssistantAppFormValues(args, status);
|
||||
const autoConfirming = useAssistantAutomaticAction({
|
||||
toolCallId,
|
||||
ready: result === undefined && initialValues !== null,
|
||||
completed: result !== undefined,
|
||||
confirm: () => {
|
||||
if (!initialValues) return;
|
||||
setSubmitting(true);
|
||||
addResult(initialValues);
|
||||
},
|
||||
});
|
||||
|
||||
if (result) {
|
||||
return (
|
||||
@@ -241,6 +306,14 @@ export const AssistantConfigureAppTool = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (autoConfirming) {
|
||||
return (
|
||||
<Box className={classes.appTool}>
|
||||
<AssistantAutomaticActionProgress label={t("automaticContinue")} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={classes.appTool}>
|
||||
<Stack gap={2} mb="md">
|
||||
|
||||
@@ -100,7 +100,7 @@ import { assistantReasoningModes } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./assistant-panel.module.css";
|
||||
import { useAssistantAutoApproval } from "./assistant-auto-approval";
|
||||
import { useAssistantAutoApproval, useAssistantAutomaticAction } from "./assistant-auto-approval";
|
||||
import { normalizeAssistantMarkdown } from "./assistant-markdown";
|
||||
import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata";
|
||||
import type { AssistantPendingAction } from "./assistant-pending-action";
|
||||
@@ -354,11 +354,8 @@ const ToolPart = ({
|
||||
timing,
|
||||
}: ToolCallMessagePartProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const autoApproval = useAssistantAutoApproval();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [approvalResponse, setApprovalResponse] = useState<"approve" | "deny" | null>(null);
|
||||
const [autoApprovalInProgress, setAutoApprovalInProgress] = useState(false);
|
||||
const autoApprovalAttemptRef = useRef<string | null>(null);
|
||||
const completed = status?.type === "complete";
|
||||
const awaitingApproval = approval !== undefined && approval.approved === undefined && !approval.resolution;
|
||||
const denied = approval?.approved === false;
|
||||
@@ -369,26 +366,15 @@ const ToolPart = ({
|
||||
(typeof result === "object" && result !== null && "error" in result));
|
||||
const successful = completed && !denied && !failed;
|
||||
const duration = timing?.completedAt !== undefined ? Math.max(0, timing.completedAt - timing.startedAt) : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!awaitingApproval) {
|
||||
autoApprovalAttemptRef.current = null;
|
||||
setAutoApprovalInProgress(false);
|
||||
return;
|
||||
}
|
||||
if (!autoApproval.enabled) {
|
||||
setAutoApprovalInProgress(false);
|
||||
return;
|
||||
}
|
||||
if (autoApprovalAttemptRef.current === toolCallId) return;
|
||||
autoApprovalAttemptRef.current = toolCallId;
|
||||
|
||||
const requested = autoApproval.requestApproval(toolCallId, () => {
|
||||
const autoApprovalInProgress = useAssistantAutomaticAction({
|
||||
toolCallId,
|
||||
ready: awaitingApproval,
|
||||
completed: !awaitingApproval,
|
||||
confirm: () => {
|
||||
respondToApproval({ approved: true, reason: "Approved automatically by the user." });
|
||||
setApprovalResponse("approve");
|
||||
});
|
||||
setAutoApprovalInProgress(requested);
|
||||
}, [autoApproval, awaitingApproval, respondToApproval, toolCallId]);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Box className={classes.tool}>
|
||||
|
||||
@@ -7,8 +7,8 @@ const supportedToolArguments = {
|
||||
ask_user: {
|
||||
question: "How should the app be added?",
|
||||
options: [
|
||||
{ id: "yes", label: "Yes" },
|
||||
{ id: "no", label: "No" },
|
||||
{ id: "yes", label: "Yes", kind: "affirmative" },
|
||||
{ id: "no", label: "No", kind: "negative" },
|
||||
],
|
||||
},
|
||||
configure_app: {
|
||||
@@ -45,9 +45,14 @@ describe("assistant human tool contracts", () => {
|
||||
browserToolContracts.ask_user.parameters.safeParse({
|
||||
question: "How should the app be added?",
|
||||
options: [
|
||||
{ id: "yes", label: "Yes" },
|
||||
{ id: "no", label: "No" },
|
||||
{ id: "alternative", label: "Choose an alternative", description: "Adjust the proposed app first." },
|
||||
{ id: "yes", label: "Yes", kind: "affirmative" },
|
||||
{ id: "no", label: "No", kind: "negative" },
|
||||
{
|
||||
id: "alternative",
|
||||
label: "Choose an alternative",
|
||||
description: "Adjust the proposed app first.",
|
||||
kind: "alternative",
|
||||
},
|
||||
],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
@@ -57,7 +62,19 @@ describe("assistant human tool contracts", () => {
|
||||
expect(
|
||||
browserToolContracts.ask_user.parameters.safeParse({
|
||||
question: "Continue?",
|
||||
options: [{ id: "yes", label: "Yes" }],
|
||||
options: [{ id: "yes", label: "Yes", kind: "affirmative" }],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("requires every structured choice to declare its category", () => {
|
||||
expect(
|
||||
browserToolContracts.ask_user.parameters.safeParse({
|
||||
question: "Continue?",
|
||||
options: [
|
||||
{ id: "yes", label: "Yes" },
|
||||
{ id: "no", label: "No" },
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -2,10 +2,18 @@ import { z } from "zod/v4";
|
||||
|
||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
||||
|
||||
export const assistantAskUserOptionKinds = ["affirmative", "negative", "alternative"] as const;
|
||||
export type AssistantAskUserOptionKind = (typeof assistantAskUserOptionKinds)[number];
|
||||
|
||||
const askUserOptionSchema = z.object({
|
||||
id: z.string().trim().min(1).max(48),
|
||||
label: z.string().trim().min(1).max(80),
|
||||
description: z.string().trim().max(180).optional(),
|
||||
kind: z
|
||||
.enum(assistantAskUserOptionKinds)
|
||||
.describe(
|
||||
"Classify agreement, approval, or proceeding as affirmative; refusal or stopping as negative; and unrelated selections as alternative.",
|
||||
),
|
||||
});
|
||||
|
||||
const boardImageValueSchema = z.string().trim().max(2_048).nullable();
|
||||
@@ -41,7 +49,7 @@ export const assistantBoardSettingsChangesSchema = z
|
||||
export const browserToolContracts = {
|
||||
ask_user: {
|
||||
description:
|
||||
"Pause and ask the user one concise structured question. Use this for missing information or a meaningful choice, never as a second confirmation before a mutating tool. Provide 2-4 distinct options; the UI adds a freeform Other choice when allowOther is not false.",
|
||||
"Pause and ask the user one concise structured question. Use this for missing information or a meaningful choice, never as a second confirmation before a mutating tool. Provide 2-4 distinct options and classify every option: agreement, approval, or proceeding is affirmative; refusal or stopping is negative; unrelated selections are alternative. A confirmation question must have exactly one affirmative option. The UI adds a freeform Other choice when allowOther is not false.",
|
||||
parameters: z.object({
|
||||
question: z.string().trim().min(1).max(240),
|
||||
description: z.string().trim().max(400).optional(),
|
||||
@@ -98,6 +106,7 @@ export type AskUserArgs = z.infer<(typeof browserToolContracts)["ask_user"]["par
|
||||
export type AskUserResult = {
|
||||
answer: string;
|
||||
optionId?: string;
|
||||
optionKind?: AssistantAskUserOptionKind;
|
||||
source: "option" | "other";
|
||||
};
|
||||
export type ConfigureAppArgs = z.infer<(typeof browserToolContracts)["configure_app"]["parameters"]>;
|
||||
|
||||
@@ -1688,14 +1688,22 @@
|
||||
"otherLabel": "Your answer",
|
||||
"otherPlaceholder": "Type another answer…",
|
||||
"submit": "Send answer",
|
||||
"cancelOther": "Cancel other answer"
|
||||
"cancelOther": "Cancel other answer",
|
||||
"automaticAffirmative": "Choosing the affirmative answer automatically",
|
||||
"optionKind": {
|
||||
"affirmative": "Confirm",
|
||||
"negative": "Decline",
|
||||
"alternative": "Option",
|
||||
"custom": "Custom"
|
||||
}
|
||||
},
|
||||
"configureApp": {
|
||||
"title": "Review app details",
|
||||
"description": "Choose a local icon and adjust anything before the assistant requests creation approval.",
|
||||
"preparing": "Preparing app details",
|
||||
"continue": "Use these details",
|
||||
"ready": "App details ready"
|
||||
"ready": "App details ready",
|
||||
"automaticContinue": "Using these app details automatically"
|
||||
},
|
||||
"configureBoardSettings": {
|
||||
"title": "Review board changes",
|
||||
@@ -1710,6 +1718,7 @@
|
||||
"noChanges": "No settings will change",
|
||||
"keepCurrent": "Keep current settings",
|
||||
"continue": "Continue to approval",
|
||||
"automaticContinue": "Accepting the proposed board changes automatically",
|
||||
"resetAll": "Reset all changes",
|
||||
"tabs": {
|
||||
"css": "Custom CSS",
|
||||
@@ -1737,7 +1746,7 @@
|
||||
"autoApproval": {
|
||||
"label": "Automatic approvals",
|
||||
"title": "Automatic approvals",
|
||||
"description": "Approve every protected change in this conversation without another click. Your Homarr permissions still apply. This turns off when you switch conversations.",
|
||||
"description": "Automatically approve protected changes, use presented form details, and choose answers categorized as affirmative. Neutral selections still wait for you. Your Homarr permissions still apply. This turns off when you switch conversations.",
|
||||
"enable": "Enable for this conversation",
|
||||
"disable": "Turn off automatic approvals",
|
||||
"enabled": "Automatic approvals are on",
|
||||
|
||||
Reference in new issue
Block a user