openhands / src /hooks /mutation /use-create-conversation.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
3d700dd verified
Raw History Blame Contribute Delete
17.5 kB
import { useMutation, useQueryClient } from "@tanstack/react-query";
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
import { PluginSpec } from "#/api/conversation-service/agent-server-conversation-service.types";
import { SuggestedTask } from "#/utils/types";
import { Provider } from "#/types/settings";
import { useTracking } from "#/hooks/use-tracking";
import { useLlmProfiles } from "#/hooks/query/use-llm-profiles";
import { useAgentProfiles } from "#/hooks/query/use-agent-profiles";
import { useActiveBackend } from "#/contexts/active-backend-context";
import ProfilesService from "#/api/profiles-service/profiles-service.api";
import AgentProfilesService, {
WELL_KNOWN_DEFAULT_AGENT_PROFILE_NAME,
type AgentProfileListResponse,
} from "#/api/agent-profiles-service/agent-profiles-service.api";
import PluginsManagementService, {
type InstalledPluginInfo,
} from "#/api/plugins-management-service";
import {
PLUGINS_QUERY_KEYS,
LLM_PROFILES_QUERY_KEYS,
AGENT_PROFILES_QUERY_KEYS,
AGENT_PROFILES_RETRY_OPTIONS,
} from "#/hooks/query/query-keys";
import { pluginReferenceKey } from "#/utils/plugin-display";
import {
getStoredConversationMetadata,
setStoredConversationMetadata,
toPluginCoordinates,
type WorkspaceMode,
} from "#/api/conversation-metadata-store";
export interface CreateConversationVariables {
query?: string;
repository?: {
name: string;
gitProvider: Provider;
branch?: string;
};
suggestedTask?: SuggestedTask;
conversationInstructions?: string;
parentConversationId?: string;
agentType?: "default" | "plan";
plugins?: PluginSpec[];
workingDir?: string;
workspaceMode?: WorkspaceMode;
// Launch from a specific AgentProfile (local backend). When omitted, the
// active AgentProfile (if any) is used so home-composed conversations
// launch from the user's selected profile (#3727).
agentProfileId?: string;
entryPoint?: string; // analytics only; not forwarded to the service
}
export const CREATE_CONVERSATION_MUTATION_KEY = ["create-conversation"];
interface CreateConversationResponse {
conversation_id: string;
session_api_key: string | null;
url: string | null;
task_id?: string;
}
export const useCreateConversation = () => {
const queryClient = useQueryClient();
const { trackConversationCreated } = useTracking();
// Cache-warm on the home page (the profile picker reads the same query).
// Stamped onto the conversation at creation so the switcher can show the
// exact profile even when several profiles share a model (#1082).
const { data: llmProfiles } = useLlmProfiles();
// Warm the agent-profiles cache too — the launch path below awaits the same
// query via ensureQueryData, so a warm cache makes home-launch instant. The
// hook's maybe-unresolved data is deliberately NOT read at launch time:
// activation is pointer-only (it never writes agent_settings), so racing a
// cold cache into the agent_settings fallback would silently launch the
// wrong agent.
const { backend, orgId } = useActiveBackend();
useAgentProfiles();
return useMutation({
mutationKey: CREATE_CONVERSATION_MUTATION_KEY,
mutationFn: async (
variables: CreateConversationVariables,
): Promise<CreateConversationResponse> => {
const {
query,
conversationInstructions,
plugins,
repository,
workingDir,
workspaceMode,
parentConversationId,
agentType,
agentProfileId,
} = variables;
// The active AgentProfile is the default launch profile for new
// conversations (#3727), on both local and cloud (cloud gained
// /api/agent-profiles in OpenHands #15060, #3730). Await the list from
// the shared query cache: a send fired before the home query resolves
// must still launch from the active profile. Do not fall back to the
// global agent_settings when profile discovery fails: activation is
// pointer-only, so those settings may describe a different agent.
const agentProfiles: AgentProfileListResponse =
await queryClient.ensureQueryData({
queryKey: [...AGENT_PROFILES_QUERY_KEYS.all, backend.id, orgId],
queryFn: AgentProfilesService.listProfiles,
...AGENT_PROFILES_RETRY_OPTIONS,
});
const requestedAgentProfileId =
agentProfileId ?? agentProfiles.active_agent_profile_id ?? undefined;
// Fall back to the legacy agent_settings launch when the resolved agent
// profile can't resolve its LLM. The agent-server seeds a `default`
// openhands profile whose `llm_profile_ref` can point at an LLM profile
// that doesn't exist (fresh store, or one configured with named profiles
// only); launching from it 404s ("LLM profile '<ref>' not found") and
// would brick home-launch. agent_settings reflects the active LLM, so the
// fallback degrades cleanly until the seed mirrors it (SDK #3933).
// ACP profiles carry no llm_profile_ref, so they're never gated here.
const resolvedAgentProfile = requestedAgentProfileId
? agentProfiles?.profiles?.find(
(profile) => profile.id === requestedAgentProfileId,
)
: undefined;
// Cloud has no `agent_settings` payload to fall back to — the downgrade
// below only makes sense on local, where it exists and carries the
// canvas-only enrichments. Gating it here keeps cloud always launching
// from the resolved profile id, so the conversation gets
// `launched_agent_profile` stamped and the profile's config applied
// (#1571 review).
const isCloud = backend.kind === "cloud";
let effectiveAgentProfileId = requestedAgentProfileId;
// The account-wide active LLM profile from the launch-path fetch below
// (null when that fetch didn't run or failed). Fresher than the
// `useLlmProfiles()` render snapshot, which a fast send can outrun.
let fetchedActiveLlmProfile: string | null = null;
if (
!isCloud &&
resolvedAgentProfile?.name === WELL_KNOWN_DEFAULT_AGENT_PROFILE_NAME &&
resolvedAgentProfile?.agent_kind === "openhands"
) {
// The seeded OpenHands `default` profile is the enriched baseline, not a
// deliberate profile pick — it mirrors global agent_settings. Launch it
// via agent_settings so the canvas-only enrichments the profile-resolution
// path drops survive for the common home-launch: the <RUNTIME_SERVICES>
// system-message suffix and project-skill loading (buildAgentContext).
// Named profiles are deliberate custom configs and still use the profile
// path (accepting that enrichment boundary).
// Trade-off: per-profile fields set on `default` itself don't apply on
// home-launch — custom per-profile config belongs in a named profile.
//
// Scoped to OpenHands: an ACP `default` must keep the profile path.
// Activation is pointer-only, so global agent_settings is stale (often
// still OpenHands) when an ACP profile is active — launching it via
// agent_settings would start the wrong agent. ACP carries no
// <RUNTIME_SERVICES> enrichment, so there's nothing to preserve.
//
// Scoped to local: cloud never writes agent_settings, so it always
// resolves `default` server-side via agent_profile_id (validated below).
effectiveAgentProfileId = undefined;
} else if (
resolvedAgentProfile?.agent_kind === "openhands" &&
resolvedAgentProfile.llm_profile_ref
) {
// Await the LLM-profile list rather than reading the maybe-unresolved
// `useLlmProfiles()` result: a send fired before that query loads (or
// after it errors) must still validate the ref, not launch blind.
let llmProfileExists = false;
try {
const llm = await queryClient.ensureQueryData({
queryKey: [...LLM_PROFILES_QUERY_KEYS.all, backend.id, orgId],
queryFn: ProfilesService.listProfiles,
// Match the agent-profiles fetch above: on a backend where this
// errors, fall back to agent_settings immediately rather than
// stalling the send through the default exponential backoff.
retry: false,
});
llmProfileExists = llm.profiles.some(
(profile) => profile.name === resolvedAgentProfile.llm_profile_ref,
);
fetchedActiveLlmProfile = llm.active_profile ?? null;
} catch {
// List unavailable → can't validate → fall back to agent_settings.
}
if (!llmProfileExists) {
// Downgrade is silent in the UI; leave a diagnosable trace.
console.warn(
`Agent profile "${resolvedAgentProfile.name}" references missing ` +
`LLM profile "${resolvedAgentProfile.llm_profile_ref}"; ` +
"launching from agent_settings instead.",
);
effectiveAgentProfileId = undefined;
} else if (
!isCloud &&
!agentProfileId &&
fetchedActiveLlmProfile &&
fetchedActiveLlmProfile !== resolvedAgentProfile.llm_profile_ref
) {
// The home LLM pill shows — and its dropdown activates — the
// account-wide active LLM profile, never the pinned ref
// (useChatInputLlmProfileState), so when the two differ the launch
// must run the selection or the UI advertises a model the
// conversation won't use (#16539). Launch via agent_settings, which
// the dropdown's profile activation syncs to the selection. Scoped
// to the implicit active-profile launch: an explicit `agentProfileId`
// (the in-conversation profile picker) is a deliberate profile pick,
// so its pinned ref stays authoritative. Local-only like the
// downgrades above — cloud has no agent_settings payload to fall
// back to. Trade-off: the named profile's non-LLM config doesn't
// apply to this launch; the start request has no per-launch LLM
// override that could preserve it (AgentLaunchAdditions carries only
// a system-message suffix).
effectiveAgentProfileId = undefined;
}
}
// Only extend the call with the profile fields when launching from a
// profile, so a plain create stays byte-identical to the legacy
// agent_settings path (#3727). sandboxId is unused here.
const conversation =
await AgentServerConversationService.createConversation({
initialUserMsg: query,
conversationInstructions,
plugins,
metadata: repository
? {
selected_repository: repository.name,
selected_branch: repository.branch ?? null,
git_provider: repository.gitProvider,
}
: null,
workingDirOverride: workingDir,
workspaceMode,
parentConversationId,
agentType,
...(effectiveAgentProfileId
? {
agentProfileId: effectiveAgentProfileId,
agentProfileKind: resolvedAgentProfile?.agent_kind,
}
: {}),
});
// Stamp the active LLM profile onto the (local) conversation so the
// chat switcher shows the exact profile even when several profiles
// share a model (#1082). Cloud conversations don't use local profiles
// (app_conversation_id stays null until the sandbox is READY). Merge so
// the repo/workspace metadata the service just persisted is preserved.
const localConversationId = conversation.app_conversation_id;
// Snapshot the conversation's plugins into client-side metadata so the
// in-conversation plugins view can show what's loaded (coordinates only
// — strip parameters, which may carry secrets). The agent-server doesn't
// return a live conversation's loaded plugins, so this snapshot is the
// source for that view. Two sources, deduped by coordinates:
// 1. plugins explicitly attached at creation (e.g. the /launch flow);
// 2. enabled installed plugins, which the SDK auto-loads into every new
// local conversation (see use-set-plugin-enabled).
const explicitPlugins = plugins?.map(toPluginCoordinates) ?? [];
let attachedPlugins: PluginSpec[] = explicitPlugins;
if (localConversationId) {
let installed: InstalledPluginInfo[] = [];
try {
installed = await queryClient.ensureQueryData({
// Same key `usePlugins` writes. Unscoped, this served the previous
// backend's list and attached plugins the target server lacks.
queryKey: [...PLUGINS_QUERY_KEYS.installed, backend.id, orgId],
queryFn: () => PluginsManagementService.listInstalledPlugins(),
});
} catch {
// Best-effort: never let plugin lookup block conversation creation.
}
const seen = new Set(explicitPlugins.map(pluginReferenceKey));
const enabledInstalled = installed
.filter((plugin) => plugin.enabled)
.map((plugin) => ({
source: plugin.source,
ref: plugin.resolved_ref ?? null,
repo_path: plugin.repo_path ?? null,
// Keep the human-friendly name so the plugins view shows e.g.
// "city-weather" rather than deriving "local" from the source.
name: plugin.name,
}))
.filter((plugin) => !seen.has(pluginReferenceKey(plugin)));
attachedPlugins = [...explicitPlugins, ...enabledInstalled];
}
// A launch from a named OpenHands profile runs that profile's
// `llm_profile_ref`, which can differ from the standalone active LLM
// profile — stamp the ref so the switcher pill names the exact profile
// the conversation runs (#1082). The agent_settings paths (the `default`
// baseline, a dangling ref, or a dropdown override (#16539) — where
// effectiveAgentProfileId is cleared) run the active LLM, so they stamp
// the active profile, preferring the launch-path fetch over the hook's
// render snapshot. ACP profiles carry no LLM profile, so they fall
// through to the active-profile stamp (unused by the ACP model chip).
const activeProfile =
effectiveAgentProfileId &&
resolvedAgentProfile?.agent_kind === "openhands"
? resolvedAgentProfile.llm_profile_ref
: (fetchedActiveLlmProfile ?? llmProfiles?.active_profile ?? null);
if (localConversationId && (activeProfile || attachedPlugins.length)) {
const prev = getStoredConversationMetadata(localConversationId);
setStoredConversationMetadata(localConversationId, {
selected_repository: prev?.selected_repository ?? null,
selected_branch: prev?.selected_branch ?? null,
git_provider: prev?.git_provider ?? null,
selected_workspace: prev?.selected_workspace ?? null,
workspace_mode: prev?.workspace_mode ?? null,
active_profile: activeProfile ?? prev?.active_profile ?? null,
plugins: attachedPlugins.length
? attachedPlugins
: (prev?.plugins ?? null),
});
}
// OpenHands cloud pattern: when the start task isn't immediately
// READY (cloud sandbox is still provisioning),
// app_conversation_id is null. We return a `task-{id}` URL so the
// conversation route's useTaskPolling can drive it to READY and
// then redirect to the real `/conversations/{app_conversation_id}`.
const conversationId = conversation.app_conversation_id
? conversation.app_conversation_id
: `task-${conversation.id}`;
return {
conversation_id: conversationId,
session_api_key: null,
url: conversation.agent_server_url,
task_id: conversation.id,
};
},
onSuccess: async (data, variables) => {
trackConversationCreated({
conversationId: data.conversation_id,
taskId: data.task_id,
hasRepository: !!variables.repository,
gitProvider: variables.repository?.gitProvider,
hasWorkspace: !!variables.workingDir,
workspaceMode: variables.workspaceMode,
hasInitialQuery: !!variables.query,
agentType: variables.agentType,
hasParentConversation: !!variables.parentConversationId,
entryPoint: variables.entryPoint,
});
// Invalidate (rather than remove) so the existing paginated list stays
// rendered while a background refetch picks up the new conversation.
// `removeQueries` would wipe the cache and force the panel back to its
// initial loading state, dropping loaded pages and scroll position.
queryClient.invalidateQueries({
queryKey: ["user", "conversations"],
});
// The cloud path returns a start task (no app_conversation_id
// yet); the sidebar surfaces those via `useStartTasks` which doesn't
// poll, so invalidate it explicitly so the in-flight task shows up
// in the conversation list immediately.
queryClient.invalidateQueries({
queryKey: ["start-tasks"],
});
},
});
};