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
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
type RuntimeSubagent,
type SubagentWorkflowGroup,
} from "@t3tools/client-runtime/state/subagentRuntime";
import { REASONING_EFFORT_OPTION_IDS } from "@t3tools/client-runtime/state/thread-execution";
import { REASONING_EFFORT_OPTION_IDS } from "@t3tools/shared/model";
import type {
EnvironmentId,
OrchestrationV2Subagent,
Expand Down
11 changes: 8 additions & 3 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1326,14 +1326,18 @@ const make = Effect.gen(function* () {
})
.pipe(Effect.mapError(threadManagementFailure));

/** An orchestrator's children default to the worker model, so its own model is not the one to inherit. */
/** An orchestrator's children default to the worker model and effort, not to its own. */
const withWorkerModel = Effect.fn("OrchestratorMcpService.withWorkerModel")(function* <
Parent extends Pick<OrchestrationV2ThreadProjection, "thread">,
>(parent: Parent) {
const settings = yield* serverSettings.getSettings.pipe(
Effect.mapError(() => failure("orchestration_error", "Unable to read server settings.")),
);
const modelSelection = orchestratorWorkerModelSelection(settings, parent.thread);
const modelSelection = orchestratorWorkerModelSelection(
settings,
parent.thread,
yield* loadProviders,
);
return modelSelection === undefined
? parent
: { ...parent, thread: { ...parent.thread, modelSelection } };
Expand Down Expand Up @@ -2358,7 +2362,8 @@ const make = Effect.gen(function* () {
Effect.mapError(() => failure("orchestration_error", "Unable to read server settings.")),
);
const inherited =
orchestratorWorkerModelSelection(settings, parent?.thread) ?? parent?.thread.modelSelection;
orchestratorWorkerModelSelection(settings, parent?.thread, providers) ??
parent?.thread.modelSelection;
const searchTerms = input.search?.toLowerCase().split(/\s+/).filter(Boolean) ?? [];
return {
parentThreadId: parent?.thread.id ?? null,
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3308,6 +3308,7 @@ export default function ChatView(props: ChatViewProps) {
activeThreadId={activeThread.id}
isServerThread={isServerThread}
activeThreadTitle={activeThread.title}
activeThreadModelSelection={activeThread.modelSelection}
activeThreadLinkOrigin={activeThreadShell?.linkOrigin?.label ?? null}
activeProject={activeProject ?? null}
parentThreadLink={headerParentThreadLink}
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
AuthOrchestrationOperateScope,
type EnvironmentId,
type ModelSelection,
type ScopedThreadRef,
type ThreadId,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -48,6 +49,7 @@ interface ChatHeaderProps {
activeThreadEnvironmentId: EnvironmentId;
activeThreadId: ThreadId;
activeThreadTitle: string;
activeThreadModelSelection?: ModelSelection | undefined;
activeThreadLinkOrigin?: string | null | undefined;
isServerThread: boolean;
activeProject: EnvironmentProject | null;
Expand All @@ -73,6 +75,7 @@ export const ChatHeader = memo(function ChatHeader({
activeThreadEnvironmentId,
activeThreadId,
activeThreadTitle,
activeThreadModelSelection,
activeThreadLinkOrigin,
isServerThread,
activeProject,
Expand Down Expand Up @@ -320,7 +323,12 @@ export const ChatHeader = memo(function ChatHeader({
</>
) : null}
<WorkspaceBreadcrumbItem current className="min-w-10 flex-1">
<OrchestratorBadge environmentId={activeThreadEnvironmentId} threadId={activeThreadId} />
<OrchestratorBadge
environmentId={activeThreadEnvironmentId}
threadId={activeThreadId}
projectId={activeProject?.id ?? null}
modelSelection={activeThreadModelSelection}
/>
{renamingTitle !== null ? (
<input
autoFocus
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/SubagentTooltipContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
resolveSubagentMetadata,
subagentDetailPreview,
} from "@t3tools/client-runtime/state/subagent-display";
import { REASONING_EFFORT_OPTION_IDS } from "@t3tools/client-runtime/state/thread-execution";
import { REASONING_EFFORT_OPTION_IDS } from "@t3tools/shared/model";
import { getModelSelectionStringOptionValue, resolveSelectableModel } from "@t3tools/shared/model";
import { getTraitsSpeedDisplay, TraitsSpeedIcon } from "./TraitsSpeed";
import type { ReactNode } from "react";
Expand Down
92 changes: 74 additions & 18 deletions apps/web/src/components/orchestrator/OrchestratorBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,86 @@
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { isOrchestratorThread } from "@t3tools/shared/orchestratorMode";
import type { EnvironmentId, ModelSelection, ProjectId, ThreadId } from "@t3tools/contracts";
import {
isOrchestratorThread,
orchestratorWorkerEffortChoices,
} from "@t3tools/shared/orchestratorMode";
import { NetworkIcon } from "lucide-react";

import { setOrchestratorWorkerEffort } from "../../lib/orchestratorMode";
import { useServerConfigs } from "../../state/entities";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { useEnvironmentProviders } from "../../state/environments";
import {
Menu,
MenuGroup,
MenuGroupLabel,
MenuItemDetail,
MenuPopup,
MenuRadioGroup,
MenuRadioItem,
MenuTrigger,
} from "../ui/menu";

/** Marks a thread whose agent runs in orchestrator mode. */
export function OrchestratorBadge(props: { environmentId: EnvironmentId; threadId: ThreadId }) {
const settings = useServerConfigs().get(props.environmentId)?.settings;
if (settings === undefined || !isOrchestratorThread(settings, props.threadId)) return null;
const DEFAULT_EFFORT = "default";

/** Marks a thread whose agent runs in orchestrator mode, and sets the effort its subagents start on. */
export function OrchestratorBadge(props: {
environmentId: EnvironmentId;
threadId: ThreadId;
projectId: ProjectId | null;
modelSelection: ModelSelection | undefined;
}) {
const config = useServerConfigs().get(props.environmentId);
const providers = useEnvironmentProviders().get(props.environmentId) ?? [];
if (config === undefined || !isOrchestratorThread(config.settings, props.threadId)) return null;
const { settings } = config;
const choices = orchestratorWorkerEffortChoices(settings, providers, {
projectId: props.projectId,
...(props.modelSelection ? { modelSelection: props.modelSelection } : {}),
});
const labelOf = (effort: string | null) =>
choices.find((choice) => choice.id === effort)?.label ?? "Model default";
const own =
settings.orchestratorThreadWorkerEfforts.find((entry) => entry.threadId === props.threadId)
?.effort ?? null;
return (
<Tooltip>
<TooltipTrigger
<Menu>
<MenuTrigger
render={
<span
role="img"
aria-label="Orchestrator mode"
className="me-1.5 inline-flex shrink-0 text-info-foreground"
<button
type="button"
aria-label="Orchestrator mode: subagent effort"
className="me-1 inline-flex shrink-0 cursor-pointer rounded-full p-0.5 text-info-foreground outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset data-popup-open:bg-accent"
/>
}
>
<NetworkIcon className="size-3.5" />
</TooltipTrigger>
<TooltipPopup side="top">
Orchestrator mode: plans the work and hands it to workers
</TooltipPopup>
</Tooltip>
</MenuTrigger>
<MenuPopup align="start">
<MenuGroup>
<MenuGroupLabel>Subagent effort in this chat</MenuGroupLabel>
<MenuRadioGroup
value={own ?? DEFAULT_EFFORT}
onValueChange={(value: string) =>
void setOrchestratorWorkerEffort(
props.environmentId,
props.threadId,
value === DEFAULT_EFFORT ? null : value,
)
}
>
<MenuRadioItem value={DEFAULT_EFFORT} closeOnClick>
<span className="flex items-center">
Default
<MenuItemDetail>{labelOf(settings.orchestratorWorkerEffort)}</MenuItemDetail>
</span>
</MenuRadioItem>
{choices.map((choice) => (
<MenuRadioItem key={choice.id} value={choice.id} closeOnClick>
{choice.label}
</MenuRadioItem>
))}
</MenuRadioGroup>
</MenuGroup>
</MenuPopup>
</Menu>
);
}
95 changes: 95 additions & 0 deletions apps/web/src/components/settings/OrchestratorSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { ProviderOptionChoice, ServerProvider } from "@t3tools/contracts";
import {
orchestratorEffortChoices,
orchestratorWorkerEffortChoices,
} from "@t3tools/shared/orchestratorMode";

import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
import { SettingsRow, SettingsSection } from "./settingsLayout";
import { searchableSetting } from "./settingsSearch";
import {
useScopedSettings,
useScopedSettingsMixed,
useUpdateScopedSettings,
} from "./useScopedSettings";

const MODEL_DEFAULT = "model-default";

function EffortSelect(props: {
label: string;
value: string | null;
mixed: boolean;
choices: ReadonlyArray<ProviderOptionChoice>;
onChange: (effort: string | null) => void;
}) {
const labelOf = (value: string | null) =>
value === null
? "Mixed"
: value === MODEL_DEFAULT
? "Model default"
: (props.choices.find((choice) => choice.id === value)?.label ?? value);
return (
<Select
value={props.mixed ? null : (props.value ?? MODEL_DEFAULT)}
onValueChange={(value) => {
if (value !== null) props.onChange(value === MODEL_DEFAULT ? null : value);
}}
>
<SelectTrigger size="sm" aria-label={props.label}>
<SelectValue>{labelOf}</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem value={MODEL_DEFAULT}>Model default</SelectItem>
{props.choices.map((choice) => (
<SelectItem key={choice.id} value={choice.id}>
{choice.label}
</SelectItem>
))}
</SelectPopup>
</Select>
);
}

/** The efforts new orchestrators and their subagents start on. */
export function OrchestratorSettings({ providers }: { providers: ReadonlyArray<ServerProvider> }) {
const settings = useScopedSettings();
const updateSettings = useUpdateScopedSettings();
const mixedEffort = useScopedSettingsMixed(["orchestratorEffort"]);
const mixedWorkerEffort = useScopedSettingsMixed(["orchestratorWorkerEffort"]);
return (
<SettingsSection id="orchestrator" title="Orchestrator">
<SettingsRow
serverScoped
settingKeys={["orchestratorEffort"]}
mixed={mixedEffort}
{...searchableSetting("orchestrator-effort")}
description="New orchestrators start on this effort. Change a running one in its composer."
control={
<EffortSelect
label="Orchestrator effort"
value={settings.orchestratorEffort}
mixed={mixedEffort}
choices={orchestratorEffortChoices(providers)}
onChange={(effort) => updateSettings({ orchestratorEffort: effort })}
/>
}
/>
<SettingsRow
serverScoped
settingKeys={["orchestratorWorkerEffort"]}
mixed={mixedWorkerEffort}
{...searchableSetting("orchestrator-subagent-effort")}
description="Subagents an orchestrator starts use this effort when their model offers it. Each orchestrator chat can set its own from the icon beside its title."
control={
<EffortSelect
label="Subagent effort"
value={settings.orchestratorWorkerEffort}
mixed={mixedWorkerEffort}
choices={orchestratorWorkerEffortChoices(settings, providers, { projectId: null })}
onChange={(effort) => updateSettings({ orchestratorWorkerEffort: effort })}
/>
}
/>
</SettingsSection>
);
}
3 changes: 3 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
import { useScopedModelDisabledReason } from "./useScopedModelAvailability";
import { useOptionalSettingsScope, useSettingsScope } from "./SettingsScopeContext";
import { scopedIdleCompaction } from "./scopedSettings";
import { OrchestratorSettings } from "./OrchestratorSettings";
import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
import { ThreadTitleInstructionsSettings } from "./ThreadTitleInstructionsSettings";
import { SnoozePresetsRow } from "./SnoozePresetsSettings";
Expand Down Expand Up @@ -1649,6 +1650,8 @@ export function ThreadsSettingsPanel() {
</SettingsSection>
) : null}

{!isProjectScope ? <OrchestratorSettings providers={serverProviders} /> : null}

<SettingsSection id="usage-limits" title="Usage limits">
<SettingsRow
serverScoped
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,22 @@ export const SETTINGS_SEARCH_ITEMS = [
environmentOnly: true,
scope: "environment-defaults",
},
{
id: "orchestrator-effort",
title: "Orchestrator effort",
to: "/settings/threads",
searchTerms: ["orchestrator mode reasoning thinking effort default"],
environmentOnly: true,
scope: "environment-defaults",
},
{
id: "orchestrator-subagent-effort",
title: "Subagent effort",
to: "/settings/threads",
searchTerms: ["orchestrator mode worker subagent delegate reasoning thinking effort default"],
environmentOnly: true,
scope: "environment-defaults",
},
{
id: "snooze-limited-threads",
title: "Snooze until limits reset",
Expand Down
Loading
Loading