openhands / src /hooks /mutation /use-switch-acp-model.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
3d700dd verified
Raw History Blame Contribute Delete
6.16 kB
import { useMutation, useQueryClient } from "@tanstack/react-query";
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
import AgentProfilesService, {
type AgentProfileListResponse,
} from "#/api/agent-profiles-service/agent-profiles-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import { mergeAgentProfileSaveInput } from "#/components/features/settings/agent-profiles/merge-agent-profile-save-input";
import { useActiveBackend } from "#/contexts/active-backend-context";
import {
AGENT_PROFILES_QUERY_KEYS,
AGENT_PROFILES_RETRY_OPTIONS,
SETTINGS_QUERY_KEYS,
} from "#/hooks/query/query-keys";
import { agentProfileDetailQueryKey } from "#/hooks/query/use-active-acp-profile-detail";
import { invalidateConversationQueries } from "./conversation-mutation-utils";
interface SwitchAcpModelVars {
/**
* When set, the ACP conversation's running model is swapped live via the
* wrapper's ``session/set_model`` (POST /switch_acp_model) and the user's
* saved default is untouched. When null (home page / no session), the model
* is persisted as the default the next conversation launches with.
*/
conversationId: string | null;
model: string;
}
/**
* ACP analog of {@link useSwitchLlmProfile}. Switches the ACP model
* per-conversation when called from inside a conversation (live in-place model
* switch; a created-but-not-yet-run conversation is a persist-only deferral on
* the server, so blank conversations work too); persists it as the launch
* default when called from the home page.
*
* The home-page persist target is the **active ACP AgentProfile**: profile
* launches resolve the agent server-side purely from the stored profile
* (``agent_profile_id`` and ``agent_settings`` are mutually exclusive launch
* sources), so writing ``agent_settings.acp_model`` would not affect them.
* Only when the profile list resolves with no active ACP profile does the
* write fall back to the ``agent_settings_diff`` the legacy launch path
* reads. A failed profile discovery propagates instead of downgrading
* (#16523): an active-profile launch ignores agent_settings, so persisting
* the pick there would silently drop it.
*
* Invalidates the same conversation/settings query keys the profile hook does
* so the chat-input model chip + conversation chip refresh with the new model.
*/
export const useSwitchAcpModel = () => {
const queryClient = useQueryClient();
const { backend, orgId } = useActiveBackend();
return useMutation({
mutationFn: async ({ conversationId, model }: SwitchAcpModelVars) => {
if (conversationId) {
await AgentServerConversationService.switchAcpModel(
conversationId,
model,
);
return;
}
// Home page: resolve the active profile from the shared query cache
// (same keys/retry policy as useCreateConversation's launch path, so a
// pick can't disagree with what a subsequent send would launch). Like
// that launch path, do not fall back to the legacy agent_settings
// persist when profile discovery fails (#16523): an active-profile
// launch ignores agent_settings, so the pick would be silently dropped.
// The rejection surfaces via the global mutation error toast.
const agentProfiles: AgentProfileListResponse =
await queryClient.ensureQueryData({
queryKey: [...AGENT_PROFILES_QUERY_KEYS.all, backend.id, orgId],
queryFn: AgentProfilesService.listProfiles,
...AGENT_PROFILES_RETRY_OPTIONS,
});
const activeProfile = agentProfiles.profiles?.find(
(profile) =>
profile.id != null &&
profile.id === agentProfiles.active_agent_profile_id,
);
if (activeProfile?.agent_kind === "acp") {
// Merge over the stored profile so a model-only pick can't wipe the
// fields this surface doesn't model (command, session mode, …).
const detail = await queryClient.ensureQueryData({
queryKey: agentProfileDetailQueryKey(
backend.id,
orgId,
activeProfile.name,
),
queryFn: () => AgentProfilesService.getProfile(activeProfile.name),
...AGENT_PROFILES_RETRY_OPTIONS,
});
await AgentProfilesService.saveProfile(
activeProfile.name,
mergeAgentProfileSaveInput(detail.profile, {
agent_kind: "acp",
acp_model: model,
}),
);
return;
}
// No active ACP profile: persist as the agent-settings default the
// legacy launch path reads. The backend deep-merges
// ``agent_settings_diff`` into the existing ``agent_settings`` dict, so
// a scalar ``acp_model`` diff updates only the model and preserves the
// selected provider + command.
await SettingsService.saveSettings({
agent_settings_diff: { acp_model: model },
});
},
onSuccess: (_data, { conversationId }) => {
if (conversationId) {
invalidateConversationQueries(queryClient, conversationId);
} else {
// Mirror useSwitchLlmProfile's home-page path: clear the stale settings
// cache so the next conversation-start reads the newly saved default,
// and refetch the settings query so the home-page chip updates. The
// agent-profiles prefix covers both the list and the detail entry the
// profile-persist path just rewrote.
SettingsService.invalidateCache();
queryClient.invalidateQueries({
queryKey: SETTINGS_QUERY_KEYS.personal(),
});
queryClient.invalidateQueries({
queryKey: AGENT_PROFILES_QUERY_KEYS.all,
});
}
},
// No meta.disableToast: unlike useSwitchLlmProfile (which tailors a
// "Switched to {name} failed" message via its own onError), there's no
// per-action message worth tailoring here, so the global mutation error
// toast reports a failed switch / profile or settings write rather than
// swallowing it.
});
};