diff --git a/AGENTS.md b/AGENTS.md index af788f888..43e351ef6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,9 +48,11 @@ instance teardown, and the dev-tools loop. This section is the QuickAdd-specific brief that a skill-less agent still needs. - Plugin id `quickadd`. Reload with `obsidian vault= plugin:reload - id=quickadd`; the runner's ready probe is `quickadd:list`. Trigger the test - action with `obsidian vault= command id=quickadd:testQuickAdd` (or via - the runner: `pnpm run obsidian:e2e -- command id=quickadd:testQuickAdd`). + id=quickadd`; the runner's ready probe is `quickadd:list`. Run a choice with + `pnpm run obsidian:e2e -- quickadd:run choice=` - note that `ok:true` + means the run finished, not that it did what you wanted. Drive anything else + through `pnpm run obsidian:e2e -- eval code=`; top-level `await` + is not allowed there, so wrap async work in an IIFE the eval can await. - The four runner scripts - `provision:e2e-vault`, `start:e2e-obsidian`, `stop:e2e-obsidian`, `obsidian:e2e` - run the shared `obsidian-e2e` bin, configured by `obsidian-e2e.config.mjs` at the repo root (plugin id, the diff --git a/docs/src/content/docs/docs/AIAssistant.md b/docs/src/content/docs/docs/AIAssistant.md index ef0d07ee6..08bb4f468 100644 --- a/docs/src/content/docs/docs/AIAssistant.md +++ b/docs/src/content/docs/docs/AIAssistant.md @@ -202,12 +202,8 @@ Use these rules when choosing a value: ### Max chunk tokens {#max-chunk-tokens} -The chunked AI prompt flow has a separate **Max chunk tokens** setting. It -controls the estimated token budget for the text inserted into `{{VALUE:chunk}}` -for each chunk. - -The system prompt and prompt template are counted separately. Values above the -selected model's estimated input budget are capped automatically. +Chunk sizing has no setting of its own. It is the `maxChunkTokens` option of +[`chunkedPrompt()`](/docs/QuickAddAPI/#max-chunk-tokens) in the script API. ### Output length {#output-length} diff --git a/docs/src/content/docs/docs/QuickAddAPI.md b/docs/src/content/docs/docs/QuickAddAPI.md index 8e91917b9..34f9919e6 100644 --- a/docs/src/content/docs/docs/QuickAddAPI.md +++ b/docs/src/content/docs/docs/QuickAddAPI.md @@ -617,7 +617,7 @@ results. Use this for inputs that are too large for a single request. - `chunkSeparator`: `RegExp` used to split `text` (default: `/\n/`) - `chunkJoiner`: String inserted between chunk results (default: `"\n"`) - `shouldMerge`: Merge small adjacent chunks up to the budget (default: `true`) - - `maxChunkTokens`: Maximum **estimated** tokens for each chunk's text (the `{{VALUE:chunk}}` portion only - the system prompt and prompt template are budgeted separately). Token counts are estimated locally; values above the model's estimated input budget are capped automatically. + - `maxChunkTokens`: Maximum **estimated** tokens for each chunk's text - see [Max chunk tokens](#max-chunk-tokens) below. **Behavior:** - Chunk sizes are estimated locally (QuickAdd no longer bundles model-specific tokenizers); the configured provider remains the source of truth for exact limits. @@ -636,6 +636,17 @@ const result = await quickAddApi.ai.chunkedPrompt( ); ``` +#### Max chunk tokens {#max-chunk-tokens} + +`maxChunkTokens` is the estimated token budget for the text inserted into +`{{VALUE:chunk}}` for each chunk. It is an option of this API call, not a +QuickAdd setting - there is no **Max chunk tokens** field anywhere in the +plugin's settings or in the Macro AI Assistant command. + +The system prompt and prompt template are counted separately. Token counts are +estimated locally, and values above the selected model's estimated input budget +are capped automatically. + ### Give the model tools to call: `ai.agent(config)` {#tool--function-calling--aiagentconfig} _Introduced in QuickAdd 2.14.0._ diff --git a/src/ai/AIAssistant.systemPromptLiteral.test.ts b/src/ai/AIAssistant.systemPromptLiteral.test.ts index db558c67f..046ca0331 100644 --- a/src/ai/AIAssistant.systemPromptLiteral.test.ts +++ b/src/ai/AIAssistant.systemPromptLiteral.test.ts @@ -18,8 +18,9 @@ import type { CommonResponse } from "./OpenAIRequest"; * * If #1572 ever makes system prompts formattable, this test fails, and whoever * changes it is the person who should also restore the preview and the token - * autocomplete in AIAssistantSettingsModal / AIAssistantCommandSettingsModal / - * AIAssistantInfiniteCommandSettingsModal. + * autocomplete in AIAssistantSettingsModal / AIAssistantCommandSettingsModal. + * (A third modal, AIAssistantInfiniteCommandSettingsModal, carried the same + * affordance; it went with the unreachable command type it configured, #1571.) */ const storeState = vi.hoisted(() => ({ diff --git a/src/ai/aiHelpers.maxChunkTokens.test.ts b/src/ai/aiHelpers.maxChunkTokens.test.ts deleted file mode 100644 index adb16a9f6..000000000 --- a/src/ai/aiHelpers.maxChunkTokens.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { DEFAULT_SETTINGS } from "src/settings"; -import { settingsStore } from "src/settingsStore"; -import { deepClone } from "src/utils/deepClone"; -import type { AIProvider } from "src/ai/Provider"; -import { - getLargestModelMaxTokens, - getMaxChunkTokensUpperBound, -} from "./aiHelpers"; -import { estimateModelInputBudget } from "./tokenEstimator"; - -function setProviders(providers: AIProvider[]): void { - settingsStore.setState({ - ai: { ...settingsStore.getState().ai, providers }, - }); -} - -function provider(name: string, models: AIProvider["models"]): AIProvider { - return { - name, - endpoint: "https://example.com/v1", - apiKey: "", - models, - modelSource: "providerApi", - }; -} - -describe("getLargestModelMaxTokens", () => { - beforeEach(() => { - settingsStore.replaceState(deepClone(DEFAULT_SETTINGS)); - }); - - it("returns the most permissive configured model's context window", () => { - setProviders([ - provider("A", [{ name: "small", maxTokens: 4096 }]), - provider("B", [{ name: "big", maxTokens: 200000 }]), - ]); - expect(getLargestModelMaxTokens()).toBe(200000); - }); - - it("ignores non-finite / non-positive maxTokens values", () => { - setProviders([ - provider("A", [ - { name: "bad", maxTokens: Number.NaN }, - { name: "zero", maxTokens: 0 }, - { name: "ok", maxTokens: 8000 }, - ]), - ]); - expect(getLargestModelMaxTokens()).toBe(8000); - }); - - it("falls back to a conservative default when no models are configured", () => { - setProviders([]); - expect(getLargestModelMaxTokens()).toBe(4096); - }); -}); - -describe("getMaxChunkTokensUpperBound", () => { - beforeEach(() => { - settingsStore.replaceState(deepClone(DEFAULT_SETTINGS)); - setProviders([ - provider("OpenAI", [{ name: "gpt-x", maxTokens: 128000 }]), - provider("Big", [{ name: "huge", maxTokens: 1000000 }]), - ]); - }); - - it("uses the selected model's budget minus the system-prompt overhead", () => { - expect(getMaxChunkTokensUpperBound("gpt-x", 0)).toBe( - estimateModelInputBudget(128000), - ); - expect(getMaxChunkTokensUpperBound("gpt-x", 1000)).toBe( - estimateModelInputBudget(128000) - 1000, - ); - }); - - it("falls back to the largest configured model for the 'Ask me' sentinel", () => { - // "Ask me" is not a real model; it must not throw and must yield the - // most-permissive bound rather than collapsing to the floor. - expect(getMaxChunkTokensUpperBound("Ask me", 0)).toBe( - estimateModelInputBudget(1000000), - ); - }); - - it("falls back for a model that no longer exists", () => { - expect(getMaxChunkTokensUpperBound("deleted-model", 0)).toBe( - estimateModelInputBudget(1000000), - ); - }); - - it("never returns below 1 even when the system prompt exceeds the budget", () => { - expect(getMaxChunkTokensUpperBound("gpt-x", 10_000_000)).toBe(1); - }); -}); diff --git a/src/ai/aiHelpers.ts b/src/ai/aiHelpers.ts index 92aa714c3..bcb922fd9 100644 --- a/src/ai/aiHelpers.ts +++ b/src/ai/aiHelpers.ts @@ -1,10 +1,6 @@ import { log } from "src/logger/logManager"; import { settingsStore } from "src/settingsStore"; import type { AIProvider, Model, ModelRef } from "./Provider"; -import { estimateModelInputBudget } from "./tokenEstimator"; - -/** Conservative context-window fallback when no model is known or configured. */ -const FALLBACK_MODEL_MAX_TOKENS = 4096; /** A model together with the provider that will serve it. */ export interface ResolvedModel { @@ -242,41 +238,3 @@ export function getModelMaxTokens(model: ModelInput) { ); } -/** - * Largest context window among all configured models, or a conservative - * fallback when none are configured. Used when the selected model is unknown at - * config time (see getMaxChunkTokensUpperBound). - */ -export function getLargestModelMaxTokens(): number { - const aiSettings = settingsStore.getState().ai; - - const tokens = aiSettings.providers - .flatMap((provider) => provider.models) - .map((model) => model.maxTokens) - .filter((value) => Number.isFinite(value) && value > 0); - - return tokens.length ? Math.max(...tokens) : FALLBACK_MODEL_MAX_TOKENS; -} - -/** - * Upper bound for the "Max chunk tokens" slider: the model's estimated input - * budget minus the system-prompt overhead, floored at 1. - * - * The selected model can be unknown at config time — the "Ask me" sentinel - * (resolved at runtime) or a model that was removed from settings. In that case - * we fall back to the most permissive configured model instead of throwing, - * which would blank the settings modal. The runtime still caps each chunk to the - * actual model's budget, so a generous UI bound never lets a request exceed the - * real limit. - */ -export function getMaxChunkTokensUpperBound( - model: ModelInput, - systemPromptTokens: number, -): number { - // Silent: this feeds a settings-modal slider bound; rendering a modal must - // not consume the warn-once budget that belongs to an actual run. - const resolved = resolveModel(model, { silent: true }); - const maxTokens = resolved?.model.maxTokens ?? getLargestModelMaxTokens(); - - return Math.max(1, estimateModelInputBudget(maxTokens) - systemPromptTokens); -} diff --git a/src/ai/modelRefPinning.ts b/src/ai/modelRefPinning.ts index 6bbba016c..22d3b1a5f 100644 --- a/src/ai/modelRefPinning.ts +++ b/src/ai/modelRefPinning.ts @@ -10,10 +10,7 @@ type AICommandish = ICommand & { }; function isAICommand(command: ICommand): command is AICommandish { - return ( - command.type === CommandType.AIAssistant || - command.type === CommandType.InfiniteAIAssistant - ); + return command.type === CommandType.AIAssistant; } /** diff --git a/src/commandLabels.ts b/src/commandLabels.ts index 5f0ccf046..f9fb091bc 100644 --- a/src/commandLabels.ts +++ b/src/commandLabels.ts @@ -3,6 +3,5 @@ export const QUICK_ADD_COMMAND_LABELS = { runTemplateFromFolder: "New note from template", applyTemplate: "Apply template to active note", reloadDev: "Reload (dev)", - testDev: "Test (dev)", openSettings: "Open settings", } as const; diff --git a/src/engine/MacroChoiceEngine.ts b/src/engine/MacroChoiceEngine.ts index 77e839144..c9ece4e7f 100644 --- a/src/engine/MacroChoiceEngine.ts +++ b/src/engine/MacroChoiceEngine.ts @@ -97,14 +97,31 @@ function getUserScriptSettings( return isRecord(settings) ? settings : undefined; } +/** + * Command types QuickAdd used to declare and no longer does. Without this, the + * generic message would tell a user whose data.json holds one of these that it + * "can come from a newer version of QuickAdd" - the opposite of the truth, and + * no help at all. A removed type is a fact we know; say the known thing. + * + * A Map, not an object literal: the key comes straight from a hand-edited + * data.json, and `{}["constructor"]` is not a miss. + */ +const RETIRED_COMMAND_TYPES = new Map([ + [ + "InfiniteAIAssistant", + 'QuickAdd has removed the "Infinite AI Assistant" command type - no released version could ever run one. Delete the step from the macro. For chunked AI prompts, use quickAddApi.ai.chunkedPrompt() in a user script.', + ], +]); + /** * Why a command could not be run, phrased for the person reading the notice. * - * Two shapes reach here, and telling them apart is the difference between - * actionable and baffling. A real-but-unknown type name is worth quoting: it is - * what the user greps for. An entry with no usable type has nothing to quote - - * printing `'undefined'` or `'[object Object]'` (a hand-authored package can put - * any JSON here) would send them looking for a command type that never existed. + * Three shapes reach here, and telling them apart is the difference between + * actionable and baffling. A type QuickAdd used to declare gets the truth about + * where it came from. Any other real type name is worth quoting: it is what the + * user greps for. An entry with no usable type has nothing to quote - printing + * `'undefined'` or `'[object Object]'` (a hand-authored package can put any JSON + * here) would send them looking for a command type that never existed. */ function describeUnknownType( type: unknown, @@ -114,6 +131,10 @@ function describeUnknownType( return `the saved entry has no ${kind} type, so QuickAdd cannot tell what it was meant to do. It is in .obsidian/plugins/quickadd/data.json.`; } + const retired = + kind === "command" ? RETIRED_COMMAND_TYPES.get(type) : undefined; + if (retired) return retired; + return `QuickAdd does not recognise the ${kind} type '${type}'. It can come from a newer version of QuickAdd, an imported package, or a hand-edited .obsidian/plugins/quickadd/data.json.`; } @@ -318,17 +339,6 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine { case CommandType.AIAssistant: await this.executeAIAssistant(command as IAIAssistantCommand); break; - case CommandType.InfiniteAIAssistant: - // Never creatable, never executable: it shipped in 1.2.0 with no - // builder entry and no engine branch, so the only way to hold one - // is a hand-edited data.json or a hand-authored package. Naming it - // here is what the `never` check costs, and it turns the silent - // drop into a visible one until the type is removed outright. - this.reportUnrunnableCommand( - command, - 'no released QuickAdd has ever been able to run an "Infinite AI Assistant" command - it cannot be created or configured in the macro builder either. Delete the step from the macro.', - ); - break; case CommandType.OpenFile: await this.executeOpenFile(command as IOpenFileCommand); break; diff --git a/src/engine/MacroChoiceEngine.unknownCommand.test.ts b/src/engine/MacroChoiceEngine.unknownCommand.test.ts index fd13fc580..f223a0ea1 100644 --- a/src/engine/MacroChoiceEngine.unknownCommand.test.ts +++ b/src/engine/MacroChoiceEngine.unknownCommand.test.ts @@ -125,12 +125,15 @@ describe("MacroChoiceEngine over a command type it cannot run (#1571)", () => { expect(errors).toHaveLength(1); }); - it("shouts about the Infinite AI Assistant command, which never ran anywhere", async () => { + it("tells the truth about a retired type, not the newer-version guess", async () => { + // A real legacy string, exactly as 2.19.x and earlier wrote it. The + // generic message would say it "can come from a newer version of + // QuickAdd" - the opposite of the truth for a type we removed ourselves. const { run, executeCommandById } = runMacro([ { id: "c1", name: "Summarise", - type: CommandType.InfiniteAIAssistant, + type: "InfiniteAIAssistant", model: "gpt-4", outputVariableName: "out", }, @@ -141,6 +144,8 @@ describe("MacroChoiceEngine over a command type it cannot run (#1571)", () => { expect(errors).toHaveLength(1); expect(errors[0]).toContain("Summarise"); expect(errors[0]).toMatch(/Infinite AI Assistant/i); + expect(errors[0]).toContain("chunkedPrompt"); + expect(errors[0]).not.toMatch(/newer version/i); expect(executeCommandById).toHaveBeenCalledWith(OBSIDIAN_COMMAND_ID); }); diff --git a/src/gui/MacroGUIs/AIAssistantInfiniteCommandSettingsModal.test.ts b/src/gui/MacroGUIs/AIAssistantInfiniteCommandSettingsModal.test.ts deleted file mode 100644 index 01f1b5f43..000000000 --- a/src/gui/MacroGUIs/AIAssistantInfiniteCommandSettingsModal.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { App } from "obsidian"; -import { DEFAULT_SETTINGS } from "src/settings"; -import { settingsStore } from "src/settingsStore"; -import { deepClone } from "src/utils/deepClone"; -import type { IInfiniteAIAssistantCommand } from "src/types/macros/QuickCommands/IAIAssistantCommand"; - -// The system-prompt field wires up a format-syntax suggester and a live -// formatter; neither is relevant to this regression, so stub them out to keep -// the modal constructable in jsdom. -vi.mock("src/quickAddInstance", () => ({ - getQuickAddInstance: () => ({}), -})); -vi.mock("./../suggesters/formatSyntaxSuggester", () => ({ - FormatSyntaxSuggester: class { - constructor() { - // no-op: real suggester needs app.dom/keymap - } - }, -})); -vi.mock("src/formatters/formatDisplayFormatter", () => ({ - FormatDisplayFormatter: class { - async format(input: string): Promise { - return input; - } - }, -})); - -import { InfiniteAIAssistantCommandSettingsModal } from "./AIAssistantInfiniteCommandSettingsModal"; - -function makeSettings(model: string): IInfiniteAIAssistantCommand { - return { - id: "infinite-test", - name: "Summarize", - type: "AIAssistant", - model, - systemPrompt: "You are a helpful assistant.", - outputVariableName: "output", - modelParameters: {}, - resultJoiner: "\n", - chunkSeparator: "\n", - maxChunkTokens: 1000, - mergeChunks: false, - } as unknown as IInfiniteAIAssistantCommand; -} - -describe("InfiniteAIAssistantCommandSettingsModal max-chunk-tokens", () => { - beforeEach(() => { - settingsStore.replaceState(deepClone(DEFAULT_SETTINGS)); - }); - - afterEach(() => { - document.body.innerHTML = ""; - }); - - it("renders the full modal when the model is the 'Ask me' sentinel instead of throwing/blanking", () => { - const settings = makeSettings("Ask me"); - - let modal: InfiniteAIAssistantCommandSettingsModal | undefined; - expect(() => { - modal = new InfiniteAIAssistantCommandSettingsModal( - new App() as App, - settings, - ); - }).not.toThrow(); - - // Both the slider (early in display()) and the System Prompt (last in - // display()) must render — proving display() did not abort midway and - // blank the modal. - const text = modal!.contentEl.textContent ?? ""; - expect(text).toContain("Max chunk tokens"); - expect(text).toContain("System prompt"); - }); - - it("renders without throwing when the configured model no longer exists", () => { - const settings = makeSettings("removed-model-9000"); - - expect(() => { - new InfiniteAIAssistantCommandSettingsModal(new App() as App, settings); - }).not.toThrow(); - }); - - it("surfaces a removed model as a disabled (missing) option instead of a blank dropdown", () => { - const settings = makeSettings("removed-model-9000"); - - const modal = new InfiniteAIAssistantCommandSettingsModal( - new App() as App, - settings, - ); - - const select = modal.contentEl.querySelector("select"); - expect(select).not.toBeNull(); - - const missing = Array.from(select!.options).find((option) => - option.textContent?.startsWith("(missing)"), - ); - expect(missing).toBeDefined(); - expect(missing!.textContent).toBe("(missing) removed-model-9000"); - expect(missing!.disabled).toBe(true); - }); - - it("still renders for a known model", () => { - const settings = makeSettings("gpt-4o"); - - let modal: InfiniteAIAssistantCommandSettingsModal | undefined; - expect(() => { - modal = new InfiniteAIAssistantCommandSettingsModal( - new App() as App, - settings, - ); - }).not.toThrow(); - expect(modal!.contentEl.textContent ?? "").toContain("Max chunk tokens"); - }); -}); diff --git a/src/gui/MacroGUIs/AIAssistantInfiniteCommandSettingsModal.ts b/src/gui/MacroGUIs/AIAssistantInfiniteCommandSettingsModal.ts deleted file mode 100644 index 0ce316265..000000000 --- a/src/gui/MacroGUIs/AIAssistantInfiniteCommandSettingsModal.ts +++ /dev/null @@ -1,268 +0,0 @@ -import type { App } from "obsidian"; -import { Modal, Setting, TextAreaComponent, debounce } from "obsidian"; -import { mountSystemPromptLiteralNote } from "../ai/systemPromptLiteralNote"; -import type { IInfiniteAIAssistantCommand } from "src/types/macros/QuickCommands/IAIAssistantCommand"; -import GenericInputPrompt from "../GenericInputPrompt/GenericInputPrompt"; -import { estimateTokenCount } from "src/ai/tokenEstimator"; -import { getMaxChunkTokensUpperBound } from "src/ai/aiHelpers"; -import { populateModelDropdown } from "../modelSelect"; -import { activeModelRef } from "src/ai/Provider"; -import { addSamplingParamSettings } from "./samplingParamSettings"; - -export class InfiniteAIAssistantCommandSettingsModal extends Modal { - public waitForClose: Promise; - - private resolvePromise: (settings: IInfiniteAIAssistantCommand) => void; - private rejectPromise: (reason?: unknown) => void; - - private settings: IInfiniteAIAssistantCommand; - private showAdvancedSettings = false; - - private get systemPromptTokenLength(): number { - // The estimate is provider-agnostic, so it no longer depends on the model. - return estimateTokenCount(this.settings.systemPrompt); - } - - constructor(app: App, settings: IInfiniteAIAssistantCommand) { - super(app); - - this.settings = settings; - - this.waitForClose = new Promise( - (resolve, reject) => { - this.rejectPromise = reject; - this.resolvePromise = resolve; - } - ); - - this.open(); - this.display(); - } - - private display(): void { - this.contentEl.empty(); - const header = this.contentEl.createEl("h2"); - header.addClass("qa-clickable-modal-title"); - - // Rename affordance is a real