Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.261.033"
VERSION = "0.261.034"
IS_DEVELOPMENT = is_development_env_enabled()

SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')
Expand Down
56 changes: 51 additions & 5 deletions application/v2_ui/src/components/chat/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useChatStore, type ComposerOptions } from '../../stores/chatStore';
import { useBootstrapStore } from '../../stores/bootstrapStore';
import { uploadDocument } from '../../lib/endpoints';
import { agentSelectionKey } from '../../lib/agents';
import { hasResolvableAgent } from '../../lib/chatRequestSelection';
import { modelSelectionKey, findModel, type ModelCatalogEntry } from '../../lib/models';
import { resolveGating } from '../../lib/composerGating';
import { useUiStore } from '../../stores/uiStore';
Expand Down Expand Up @@ -93,6 +94,19 @@ export function Composer() {
selectedDocumentIds: [],
});

// An agent supplies its own deployment and never receives a reasoning level, so a
// selection the server can actually resolve is what puts the model picker into its
// overridden state. Resolved against the catalog, not the raw key, so a stale selection
// does not silently deactivate a control that is still in force.
const agentActive = useMemo(
() =>
hasResolvableAgent(
bootstrap?.catalogs?.agents as Record<string, unknown>[] | undefined,
options.agentSelection,
),
[bootstrap, options.agentSelection],
);

// Which controls are relevant right now. Deep research and Read URLs depend on what is
// currently typed, not only on what is enabled.
const gating = useMemo(
Expand All @@ -103,8 +117,16 @@ export function Composer() {
webSearchActive: options.webSearch,
urlAccessActive: options.urlAccess,
imageGenerationActive: options.imageGeneration,
agentActive,
}),
[text, features, options.webSearch, options.urlAccess, options.imageGeneration],
[
text,
features,
options.webSearch,
options.urlAccess,
options.imageGeneration,
agentActive,
],
);

// A control that stops being relevant must not leave its option set behind it, or the
Expand Down Expand Up @@ -184,6 +206,13 @@ export function Composer() {
}),
);

// Names the agent in the model picker's tooltip. Saying which one is holding the model
// back is the difference between an explanation and a control that has simply gone dim.
const activeAgentLabel = agentActive
? (agentOptions.find((option) => option.value === options.agentSelection)?.label ??
'the selected agent')
: null;

// Reasoning support is per-model, so the control appears only when the current model
// actually offers a choice. Resolved from the catalog record rather than the label,
// since the display name can be anything an administrator typed.
Expand Down Expand Up @@ -289,16 +318,29 @@ export function Composer() {

<div className="flex flex-wrap items-center gap-1.5 px-1 pt-1">
{/* Hidden while generating an image: the request goes to an image
endpoint that does not take a chat model. */}
endpoint that does not take a chat model. Shown but overridden
while an agent is selected, since the agent brings its own
deployment — picking a model here is how the user gets back to
using one, so it stays usable rather than disabled. */}
{gating.showModelPicker && (
<Dropdown
options={modelOptions}
value={options.modelDeployment}
placeholder="Model"
inactive={gating.modelPickerInactive}
title={
activeAgentLabel
? `${activeAgentLabel} supplies its own model. Pick a model to use one instead.`
: undefined
}
onChange={(value) =>
setOptions((current) => ({
...current,
modelDeployment: value,
// Choosing a model is the way out of agent mode. The
// two cannot both apply, and the server reads a model
// sent alongside an agent as an override of it.
agentSelection: undefined,
}))
}
/>
Expand All @@ -314,6 +356,9 @@ export function Composer() {
onChange={(value) =>
setOptions((current) => ({
...current,
// The model selection is kept, not cleared: it is
// simply not in force, and it comes back the moment
// the agent is cleared.
agentSelection: value,
}))
}
Expand Down Expand Up @@ -410,9 +455,10 @@ export function Composer() {
/>
)}

{/* Only shown when the selected model offers a real choice; the
endpoint strips the parameter for models that reject it. */}
{reasoningLevels.length > 0 && (
{/* Only shown when a reasoning level is a real choice: the selected
model has to offer one, and neither an agent nor image generation
can be in play, because neither carries the parameter. */}
{gating.showReasoning && reasoningLevels.length > 0 && (
<Dropdown
options={reasoningLevels}
value={options.reasoningEffort}
Expand Down
24 changes: 21 additions & 3 deletions application/v2_ui/src/components/ui/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ interface DropdownProps {
/** Allows the current selection to be cleared from within the menu. */
clearable?: boolean;
disabled?: boolean;
/**
* The selection is retained but currently overridden by something else, so the trigger
* shows the placeholder in its muted styling.
*
* Deliberately distinct from `disabled`: the control stays usable, because choosing a
* value is how the user takes the override back off. The menu still marks the retained
* value, so it is visible what returns.
*/
inactive?: boolean;
/** Tooltip for the trigger. Explains an `inactive` state, where one is not obvious. */
title?: string;
align?: 'left' | 'right';
/** Renders the trigger as an icon-sized button with the label as a tooltip. */
compact?: boolean;
Expand All @@ -43,6 +54,8 @@ export function Dropdown({
icon,
clearable = false,
disabled = false,
inactive = false,
title,
align = 'left',
compact = false,
}: DropdownProps) {
Expand All @@ -55,6 +68,10 @@ export function Dropdown({
const containerRef = useRef<HTMLDivElement>(null);

const selected = options.find((option) => option.value === value);
// What the trigger reads as. An overridden selection is still `selected` for the menu's
// check mark, but the trigger falls back to the placeholder so the row does not claim a
// choice that is not in force.
const triggerLabel = inactive ? placeholder : (selected?.label ?? placeholder);

const measure = useCallback(() => {
const element = containerRef.current;
Expand Down Expand Up @@ -122,19 +139,20 @@ export function Dropdown({
}}
aria-haspopup="listbox"
aria-expanded={open}
title={compact ? (selected?.label ?? placeholder) : undefined}
title={title ?? (compact ? (selected?.label ?? placeholder) : undefined)}
className={clsx(
'inline-flex h-9 items-center gap-2 rounded-xl border border-edge',
'bg-surface-1 text-sm font-medium transition-colors',
'hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50',
compact ? 'w-9 justify-center' : 'max-w-[14rem] px-3',
selected ? 'text-text-1' : 'text-text-3',
selected && !inactive ? 'text-text-1' : 'text-text-3',
inactive && 'opacity-70',
)}
>
{icon}
{!compact && (
<>
<span className="truncate">{selected?.label ?? placeholder}</span>
<span className="truncate">{triggerLabel}</span>
<ChevronDown size={14} className="ml-auto shrink-0 opacity-60" />
</>
)}
Expand Down
91 changes: 91 additions & 0 deletions application/v2_ui/src/lib/chatRequestSelection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// chatRequestSelection.ts
// Turning the composer's model / agent / reasoning selections into request fields.
//
// The rule this file exists for: AN AGENT WINS. When one is selected it answers with its own
// deployment (`azure_openai_gpt_deployment`, read by semantic_kernel_loader.py), and
// `reasoning_effort` only ever reaches the direct-model path
// (`_resolve_reasoning_effort_for_model` in route_backend_chats.py). So neither the model
// identity nor the reasoning level can be acted on, and sending them is not merely redundant:
//
// should_use_default_model = (
// _has_chat_agent_selection(request_agent_info)
// and settings.get('enable_multi_model_endpoints', False)
// and not data.get('model_id')
// and not data.get('model_endpoint_id')
// )
// (route_backend_chats.py)
//
// A model identity sent alongside `agent_info` reads to the server as a deliberate override,
// so the agent's own default-model handling never runs. The server has that handling for
// every configuration -- the multi-endpoint default, the first APIM deployment, and the
// configured default model -- and a request that always names a model never reaches any of it.
//
// The classic client does send a model alongside an agent, because `getCurrentModelSelection`
// reads the model select without checking that agent mode has hidden it. That is the bug
// being fixed here rather than the behaviour being matched.

import { agentInfoForSelection } from './agents';
import { modelIdentityForSelection, type ModelCatalogEntry } from './models';
import type { Json } from './types';

export interface SelectionInput {
/** Agent catalog from bootstrap. */
agents?: Record<string, unknown>[];
/** Model catalog from bootstrap. */
models?: ModelCatalogEntry[];
/** Picker selection key for the agent, if one is chosen. */
agentSelection?: string;
/** Picker selection key for the model. Not a deployment name. */
modelDeployment?: string;
reasoningEffort?: string;
}

/** The mutually exclusive halves of a chat request's routing. */
export interface SelectionFields {
agent_info?: Json;
model_deployment?: string;
model_id?: string;
model_endpoint_id?: string;
model_provider?: string;
reasoning_effort?: string;
}

/**
* Build the routing fields for a chat request.
*
* Resolution is against the catalog, not the raw selection key, so a selection left over from
* a catalog that no longer contains it degrades to model mode rather than producing a request
* that claims an agent the server cannot find.
*/
export function buildSelectionFields(input: SelectionInput): SelectionFields {
const agentInfo = input.agentSelection
? agentInfoForSelection(input.agents, input.agentSelection)
: null;

if (agentInfo) {
return { agent_info: agentInfo as Json };
}

const fields: SelectionFields = {
...modelIdentityForSelection(input.models, input.modelDeployment),
};

if (input.reasoningEffort) {
fields.reasoning_effort = input.reasoningEffort;
}

return fields;
}

/**
* Whether a selection resolves to an agent the server can act on.
*
* The composer greys out the model picker on this answer rather than on the raw selection
* key, so an unresolvable agent leaves the model picker looking exactly as live as it is.
*/
export function hasResolvableAgent(
agents: Record<string, unknown>[] | undefined,
agentSelection: string | undefined,
): boolean {
return Boolean(agentSelection && agentInfoForSelection(agents, agentSelection));
}
32 changes: 31 additions & 1 deletion application/v2_ui/src/lib/composerGating.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
//
// Showing every capability at all times is what makes the row crowded, and offering
// "Read URLs" when there is no URL to read is an invitation to a confusing result.
//
// The model and reasoning controls are gated on the agent selection for the same reason. An
// agent answers with its own deployment (`azure_openai_gpt_deployment`, read by
// semantic_kernel_loader.py), and `reasoning_effort` only ever reaches the direct-model path
// (`_resolve_reasoning_effort_for_model` in route_backend_chats.py). Leaving either control
// looking live under an agent advertises a choice the request cannot act on.

/** Matches the URL detection in the classic client. */
const URL_PATTERN = /https?:\/\/[^\s<>'"]+/gi;
Expand All @@ -26,6 +32,8 @@ export interface GatingInput {
webSearchActive: boolean;
urlAccessActive: boolean;
imageGenerationActive: boolean;
/** True while an agent is selected in the composer. */
agentActive: boolean;
}

export interface ControlGating {
Expand All @@ -42,14 +50,34 @@ export interface ControlGating {
*/
disabledByImageGeneration: boolean;
showModelPicker: boolean;
/**
* The model picker is retained but overridden: an agent is selected, so it supplies the
* deployment. The picker stays usable, because choosing a model is how the user gets
* back out of agent mode.
*/
modelPickerInactive: boolean;
/**
* Whether a reasoning level is a real choice right now. Mirrors
* `updateReasoningButtonVisibility` in static/js/chat/chat-reasoning.js, which hides the
* control for image generation and for agents alike. Model support is a separate
* question, resolved from the catalog by the caller.
*/
showReasoning: boolean;
}

function enabled(features: Record<string, unknown>, key: string): boolean {
return features?.[key] === true;
}

export function resolveGating(input: GatingInput): ControlGating {
const { prompt, features, webSearchActive, urlAccessActive, imageGenerationActive } = input;
const {
prompt,
features,
webSearchActive,
urlAccessActive,
imageGenerationActive,
agentActive,
} = input;
const urls = promptUrls(prompt);
const hasUrls = urls.length > 0;

Expand All @@ -70,5 +98,7 @@ export function resolveGating(input: GatingInput): ControlGating {
showFileUpload: enabled(features, 'enable_chat_file_uploads'),
disabledByImageGeneration: imageGenerationActive,
showModelPicker: !imageGenerationActive,
modelPickerInactive: agentActive,
showReasoning: !agentActive && !imageGenerationActive,
};
}
Loading