Skip to content
Open
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
38 changes: 38 additions & 0 deletions src/components/modal/AgentEditorModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,29 @@ function openModelSelectionModal() {
}).open();
}

// The window is filled from provider metadata when a model is picked; this lets the user
// override it (e.g. to match an Ollama num_ctx their hardware can hold). Re-picking the same
// model keeps the override — see buildPersistedChatModel (#542).
// Bumped on a rejected edit to remount the field, which otherwise keeps showing the invalid
// value it bound locally.
let contextWindowFieldKey = $state(0);

function updateChatContextWindow(value: number) {
const chatModel = selectedAgent?.chatModel;
if (!chatModel) return;
const contextWindow = Math.round(value);
if (!Number.isFinite(contextWindow) || contextWindow < 1) {
new Notice("Context window must be a positive number of tokens.");
contextWindowFieldKey++;
return;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
if (contextWindow === chatModel.modelConfig?.contextWindow) return;
pluginData.updateAgent(agentId, {
chatModel: { ...chatModel, modelConfig: { ...chatModel.modelConfig, contextWindow } },
Comment thread
greptile-apps[bot] marked this conversation as resolved.
});
void applyChanges();
}

function openSummarizationModelSelectionModal() {
const currentSelection = selectedAgent?.summarizationModel
? {
Expand Down Expand Up @@ -784,6 +807,21 @@ function getServerToolsState(serverId: string): MCPServerToolsState | undefined
/>
</SettingItem>

{#if selectedAgent.chatModel}
<SettingItem
name="Context window"
desc="Tokens the chat model can take in. Decides when older history is summarized, and for Ollama it is also the num_ctx sent with each request. Filled in from the provider when you pick a model."
>
{#key contextWindowFieldKey}
<Text
inputType="number"
value={selectedAgent.chatModel.modelConfig?.contextWindow ?? 128000}
onchange={updateChatContextWindow}
/>
{/key}
</SettingItem>
{/if}

<!-- The agent's whole definition is one note: base instructions, the current-date
section, and the memory section, with live values written as placeholders that
assembly substitutes. There is no memory toggle — an agent participates in memory
Expand Down
13 changes: 9 additions & 4 deletions src/utils/persistedChatModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { useAvailableModels } from "../hooks/useAvailableModels.svelte";
/**
* Build the `ChatModel` record persisted on an agent when a model is picked.
*
* Hydrated catalogue metadata wins where available; otherwise the previously stored
* config is preserved so a re-pick of the same model doesn't silently drop a tuned
* context window or temperature.
* Re-picking the model already stored keeps its stored context window: that value may be
* user-tuned (e.g. an Ollama `num_ctx` sized to the machine), and catalogue metadata —
* which falls back to a 128k default when the provider reports nothing — must not
* silently replace it (#542). Picking a different model takes the hydrated catalogue
* value. Vision support follows the catalogue where known; temperature is always kept.
*
* Shared by every model picker (composer pill, agent editor, onboarding, and the
* "select a model" notice action) so a model selected from any of them persists
Expand All @@ -15,11 +17,14 @@ import { useAvailableModels } from "../hooks/useAvailableModels.svelte";
export function buildPersistedChatModel(provider: string, model: string, existing?: ChatModel | null): ChatModel {
const models = useAvailableModels();
const hydrated = models.hydratedChatModelsByKey.get(`${provider}:${model}`);
const sameModel = existing?.provider === provider && existing?.model === model;
const storedContextWindow = sameModel ? existing?.modelConfig?.contextWindow : undefined;
return {
provider,
model,
modelConfig: {
contextWindow: hydrated?.contextWindow ?? existing?.modelConfig?.contextWindow ?? 128000,
contextWindow:
storedContextWindow ?? hydrated?.contextWindow ?? existing?.modelConfig?.contextWindow ?? 128000,
supportsVision: hydrated?.capabilities.vision ?? existing?.modelConfig?.supportsVision,
temperature: existing?.modelConfig?.temperature,
},
Expand Down
83 changes: 83 additions & 0 deletions test/utils/persistedChatModel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HydratedChatModelMetadata } from "../../src/types/modelMetadata";

const hydratedChatModelsByKey = new Map<string, HydratedChatModelMetadata>();

vi.mock("../../src/hooks/useAvailableModels.svelte", () => ({
useAvailableModels: () => ({ hydratedChatModelsByKey }),
}));

import { buildPersistedChatModel } from "../../src/utils/persistedChatModel";

function hydrated(provider: string, variantKey: string, contextWindow: number, vision?: boolean) {
hydratedChatModelsByKey.set(`${provider}:${variantKey}`, {
kind: "chat",
provider,
variantKey,
displayName: variantKey,
contextWindow,
capabilities: { vision },
});
}

describe("buildPersistedChatModel", () => {
beforeEach(() => {
hydratedChatModelsByKey.clear();
});

it("keeps a stored context window when the same model is re-picked (#542)", () => {
hydrated("ollama", "qwen3:8b", 128000);
const existing = { provider: "ollama", model: "qwen3:8b", modelConfig: { contextWindow: 16384 } };

const result = buildPersistedChatModel("ollama", "qwen3:8b", existing);

expect(result.modelConfig.contextWindow).toBe(16384);
});

it("takes the catalogue context window when a different model is picked", () => {
hydrated("ollama", "llama3.2:3b", 131072);
const existing = { provider: "ollama", model: "qwen3:8b", modelConfig: { contextWindow: 16384 } };

const result = buildPersistedChatModel("ollama", "llama3.2:3b", existing);

expect(result.modelConfig.contextWindow).toBe(131072);
});

it("treats the same model name on another provider as a different model", () => {
hydrated("openrouter", "qwen3:8b", 40960);
const existing = { provider: "ollama", model: "qwen3:8b", modelConfig: { contextWindow: 16384 } };

const result = buildPersistedChatModel("openrouter", "qwen3:8b", existing);

expect(result.modelConfig.contextWindow).toBe(40960);
});

it("uses the catalogue value when the same model has no stored context window", () => {
hydrated("ollama", "qwen3:8b", 40960);
const existing = { provider: "ollama", model: "qwen3:8b", modelConfig: {} };

const result = buildPersistedChatModel("ollama", "qwen3:8b", existing);

expect(result.modelConfig.contextWindow).toBe(40960);
});

it("falls back to the previous value, then 128000, when the catalogue has no entry", () => {
const existing = { provider: "ollama", model: "qwen3:8b", modelConfig: { contextWindow: 16384 } };

expect(buildPersistedChatModel("ollama", "llama3.2:3b", existing).modelConfig.contextWindow).toBe(16384);
expect(buildPersistedChatModel("ollama", "llama3.2:3b", null).modelConfig.contextWindow).toBe(128000);
});

it("follows the catalogue for vision and keeps the stored temperature", () => {
hydrated("ollama", "qwen3:8b", 40960, true);
const existing = {
provider: "ollama",
model: "qwen3:8b",
modelConfig: { contextWindow: 16384, supportsVision: false, temperature: 0.2 },
};

const result = buildPersistedChatModel("ollama", "qwen3:8b", existing);

expect(result.modelConfig).toEqual({ contextWindow: 16384, supportsVision: true, temperature: 0.2 });
});
});
Loading