feat(ai): make agent turn limit configurable
This commit is contained in:
1 parent
21e9578f78
commit
9253203d99
21 files changed
+307
-5
No files matched your search
@@ -48,6 +48,7 @@ import {
|
||||
} from "@/stores/settingsStore";
|
||||
import { createRunStatementButtonDom, loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
|
||||
import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering";
|
||||
import { MAX_AGENT_TURNS_DEFAULT, MAX_AGENT_TURNS_MAX, MAX_AGENT_TURNS_MIN, maxAgentTurnsOutOfRange, normalizeMaxAgentTurns } from "@/lib/ai/maxAgentTurns";
|
||||
import { normalizeAiModelEffortLevels, normalizeClaudeCodeReasoningLevel } from "@/lib/ai/aiModelEffort";
|
||||
import ThemeCustomizerDialog from "./ThemeCustomizerDialog.vue";
|
||||
import TunnelProfileManager from "@/components/connection/TunnelProfileManager.vue";
|
||||
@@ -65,6 +66,8 @@ import {
|
||||
forgetWebdavSyncSecretsPassphrase,
|
||||
forgetWebdavSavedPassword,
|
||||
getAppSupportInfo,
|
||||
loadMaxAgentTurns,
|
||||
saveMaxAgentTurns,
|
||||
saveWebdavSyncSecretsPreference,
|
||||
saveWebdavSavedPassword,
|
||||
saveSnippetSavedToken,
|
||||
@@ -1954,6 +1957,7 @@ watch(activeSettingsTab, async (tab) => {
|
||||
if (tab === "mcp" && !mcpStatus.value && !mcpStatusLoading.value) void refreshMcpStatus();
|
||||
if (tab === "ai" && aiIsCliProvider.value) void ensureCliMcpStatus();
|
||||
if (tab === "ai") {
|
||||
void loadMaxAgentTurnsSetting();
|
||||
// Await completion so we don't snapshot an empty default when the store
|
||||
// is still loading its first payload (init via App.vue is fire-and-forget).
|
||||
await promptTemplateStore.ensureLoaded();
|
||||
@@ -2137,6 +2141,45 @@ function globalInstructionsTooLong(): boolean {
|
||||
return promptTemplateCharacterCount(editGlobalInstructions.value) > GLOBAL_INSTRUCTIONS_MAX;
|
||||
}
|
||||
|
||||
// Agent turn limit for DBX's API-backed agent loop. CLI providers enforce their own limits.
|
||||
// Mirrors DEFAULT/MIN/MAX_MAX_AGENT_TURNS in crates/dbx-core/src/agent_loop.rs —
|
||||
// keep in sync; the backend clamp on save/load is the actual source of truth.
|
||||
const editMaxAgentTurns = ref<number | undefined>(undefined);
|
||||
const maxAgentTurnsSaving = ref(false);
|
||||
const maxAgentTurnsLoaded = ref(false);
|
||||
const maxAgentTurnsLoading = ref(false);
|
||||
const maxAgentTurnsLoadError = ref("");
|
||||
|
||||
async function loadMaxAgentTurnsSetting() {
|
||||
if (maxAgentTurnsLoaded.value || maxAgentTurnsLoading.value) return;
|
||||
maxAgentTurnsLoading.value = true;
|
||||
maxAgentTurnsLoadError.value = "";
|
||||
try {
|
||||
editMaxAgentTurns.value = await loadMaxAgentTurns();
|
||||
maxAgentTurnsLoaded.value = true;
|
||||
} catch (e: any) {
|
||||
maxAgentTurnsLoadError.value = e?.message || String(e);
|
||||
toast(maxAgentTurnsLoadError.value, 5000);
|
||||
} finally {
|
||||
maxAgentTurnsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMaxAgentTurnsSetting() {
|
||||
if (!maxAgentTurnsLoaded.value) return;
|
||||
const clamped = normalizeMaxAgentTurns(editMaxAgentTurns.value);
|
||||
maxAgentTurnsSaving.value = true;
|
||||
try {
|
||||
await saveMaxAgentTurns(clamped);
|
||||
editMaxAgentTurns.value = clamped;
|
||||
toast(t("ai.maxAgentTurnsSaved"));
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
maxAgentTurnsSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// AI Config Delete Confirmation
|
||||
const aiDeleteConfirmOpen = ref(false);
|
||||
const aiDeleteConfigId = ref<string | null>(null);
|
||||
@@ -4768,6 +4811,28 @@ onUnmounted(cleanupPreviewEditor);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agent Turn Limit (list mode, global) -->
|
||||
<div v-if="aiConfigListMode === 'list'" class="space-y-3">
|
||||
<Separator />
|
||||
<div>
|
||||
<h3 class="text-sm font-medium">{{ t("ai.maxAgentTurns") }}</h3>
|
||||
<p class="text-xs text-muted-foreground">{{ t("ai.maxAgentTurnsDescription") }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input v-model.number="editMaxAgentTurns" type="number" :min="MAX_AGENT_TURNS_MIN" :max="MAX_AGENT_TURNS_MAX" step="1" class="h-8 w-32 text-xs" :placeholder="String(MAX_AGENT_TURNS_DEFAULT)" :disabled="!maxAgentTurnsLoaded || maxAgentTurnsSaving" />
|
||||
<span class="text-xs" :class="maxAgentTurnsOutOfRange(editMaxAgentTurns) ? 'text-destructive' : 'text-muted-foreground'">
|
||||
{{ t("ai.maxAgentTurnsRange", { min: MAX_AGENT_TURNS_MIN, max: MAX_AGENT_TURNS_MAX, default: MAX_AGENT_TURNS_DEFAULT }) }}
|
||||
</span>
|
||||
<div class="flex-1"></div>
|
||||
<Button v-if="maxAgentTurnsLoadError" type="button" size="sm" variant="outline" :disabled="maxAgentTurnsLoading" @click="loadMaxAgentTurnsSetting">
|
||||
{{ t("common.retry") }}
|
||||
</Button>
|
||||
<Button type="button" size="sm" :disabled="!maxAgentTurnsLoaded || maxAgentTurnsSaving || maxAgentTurnsOutOfRange(editMaxAgentTurns)" @click="saveMaxAgentTurnsSetting">
|
||||
{{ maxAgentTurnsSaving ? t("common.processing") : t("common.save") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prompt Templates Management (list mode) -->
|
||||
<div v-if="aiConfigListMode === 'list'" class="space-y-3">
|
||||
<Separator />
|
||||
|
||||
@@ -1679,6 +1679,10 @@ export default {
|
||||
globalInstructionsSave: "Save",
|
||||
globalInstructionsTooLong: "Global instructions too long (max {max} characters)",
|
||||
customInstructionsLoadFailed: "Failed to load custom AI instructions. Please try again.",
|
||||
maxAgentTurns: "Agent Turn Limit",
|
||||
maxAgentTurnsDescription: "Maximum tool-call turns per API agent run before it pauses and asks you to continue. CLI providers use their own run limits.",
|
||||
maxAgentTurnsRange: "{min}–{max}, default {default}",
|
||||
maxAgentTurnsSaved: "Agent turn limit saved",
|
||||
promptTemplates: "Scenario Prompt Templates",
|
||||
promptTemplatesDescription: "Select one or more templates in the AI assistant to inject scenario-specific conventions.",
|
||||
promptTemplateNew: "New Template",
|
||||
|
||||
@@ -1553,6 +1553,10 @@ export default withEnglishFallback({
|
||||
globalInstructionsSave: "Guardar",
|
||||
globalInstructionsTooLong: "Instrucciones globales demasiado largas (máx. {max} caracteres)",
|
||||
customInstructionsLoadFailed: "Error al cargar las instrucciones personalizadas de IA. Inténtalo de nuevo.",
|
||||
maxAgentTurns: "Límite de turnos del agente",
|
||||
maxAgentTurnsDescription: "Máximo de turnos de llamadas a herramientas por ejecución del agente API antes de pausar y pedir continuar. Los proveedores CLI usan sus propios límites.",
|
||||
maxAgentTurnsRange: "{min}–{max}, predeterminado {default}",
|
||||
maxAgentTurnsSaved: "Límite de turnos del agente guardado",
|
||||
promptTemplates: "Plantillas de prompt por escenario",
|
||||
promptTemplatesDescription: "Selecciona una o más plantillas en el asistente de IA para inyectar convenciones específicas del escenario.",
|
||||
promptTemplateNew: "Nueva plantilla",
|
||||
|
||||
@@ -1503,6 +1503,10 @@ export default withEnglishFallback({
|
||||
globalInstructionsSave: "Salva",
|
||||
globalInstructionsTooLong: "Istruzioni globali troppo lunghe (massimo {max} caratteri)",
|
||||
customInstructionsLoadFailed: "Impossibile caricare le istruzioni AI personalizzate. Riprova.",
|
||||
maxAgentTurns: "Limite di turni dell'agente",
|
||||
maxAgentTurnsDescription: "Numero massimo di turni di chiamate agli strumenti per esecuzione dell'agente API prima di mettere in pausa e chiedere di continuare. I provider CLI usano limiti propri.",
|
||||
maxAgentTurnsRange: "{min}–{max}, predefinito {default}",
|
||||
maxAgentTurnsSaved: "Limite di turni dell'agente salvato",
|
||||
promptTemplates: "Modelli di prompt per scenario",
|
||||
promptTemplatesDescription: "Seleziona uno o più modelli nell'assistente AI per inserire convenzioni specifiche dello scenario.",
|
||||
promptTemplateNew: "Nuovo modello",
|
||||
|
||||
@@ -1557,6 +1557,10 @@ export default withEnglishFallback({
|
||||
globalInstructionsSave: "保存",
|
||||
globalInstructionsTooLong: "グローバル指示が長すぎます(最大{max}文字)",
|
||||
customInstructionsLoadFailed: "カスタムAI指示の読み込みに失敗しました。もう一度お試しください。",
|
||||
maxAgentTurns: "エージェントターン上限",
|
||||
maxAgentTurnsDescription: "1回のAPIエージェント実行あたりの最大ツール呼び出しターン数。超過すると一時停止し、続行を求めます。CLIプロバイダーは独自の実行制限を使用します。",
|
||||
maxAgentTurnsRange: "{min}–{max}、デフォルト {default}",
|
||||
maxAgentTurnsSaved: "エージェントターン上限を保存しました",
|
||||
promptTemplates: "シナリオプロンプトテンプレート",
|
||||
promptTemplatesDescription: "AIアシスタントで1つ以上のテンプレートを選択し、シナリオ固有の規約を注入します。",
|
||||
promptTemplateNew: "新規テンプレート",
|
||||
|
||||
@@ -1555,6 +1555,10 @@ export default withEnglishFallback({
|
||||
globalInstructionsSave: "Salvar",
|
||||
globalInstructionsTooLong: "Instruções globais muito longas (máx. {max} caracteres)",
|
||||
customInstructionsLoadFailed: "Falha ao carregar instruções personalizadas de IA. Tente novamente.",
|
||||
maxAgentTurns: "Limite de turnos do agente",
|
||||
maxAgentTurnsDescription: "Máximo de turnos de ferramentas por execução do agente de API antes de pausar e pedir para continuar. Provedores CLI usam seus próprios limites.",
|
||||
maxAgentTurnsRange: "{min}–{max}, padrão {default}",
|
||||
maxAgentTurnsSaved: "Limite de turnos do agente salvo",
|
||||
promptTemplates: "Modelos de prompt por cenário",
|
||||
promptTemplatesDescription: "Selecione um ou mais modelos no assistente de IA para injetar convenções específicas do cenário.",
|
||||
promptTemplateNew: "Novo modelo",
|
||||
|
||||
@@ -1679,6 +1679,10 @@ export default withEnglishFallback({
|
||||
globalInstructionsSave: "保存",
|
||||
globalInstructionsTooLong: "全局指令过长(最多 {max} 字符)",
|
||||
customInstructionsLoadFailed: "自定义 AI 规范加载失败,请稍后重试。",
|
||||
maxAgentTurns: "Agent 回合上限",
|
||||
maxAgentTurnsDescription: "单次 API Agent 运行的最大工具调用回合数,超过后会暂停并提示继续。CLI 供应商使用各自的运行限制。",
|
||||
maxAgentTurnsRange: "{min}–{max},默认 {default}",
|
||||
maxAgentTurnsSaved: "Agent 回合上限已保存",
|
||||
promptTemplates: "场景 Prompt 模板",
|
||||
promptTemplatesDescription: "在 AI 助手中选择一个或多个模板,注入场景化的约定规范。",
|
||||
promptTemplateNew: "新建模板",
|
||||
|
||||
@@ -1505,6 +1505,10 @@ export default withEnglishFallback({
|
||||
globalInstructionsSave: "儲存",
|
||||
globalInstructionsTooLong: "全域指令過長(最多 {max} 字元)",
|
||||
customInstructionsLoadFailed: "無法載入自訂 AI 規範,請稍後重試。",
|
||||
maxAgentTurns: "Agent 回合上限",
|
||||
maxAgentTurnsDescription: "單次 API Agent 執行的最大工具呼叫回合數,超過後會暫停並提示繼續。CLI 供應商使用各自的執行限制。",
|
||||
maxAgentTurnsRange: "{min}–{max},預設 {default}",
|
||||
maxAgentTurnsSaved: "Agent 回合上限已儲存",
|
||||
promptTemplates: "場景 Prompt 範本",
|
||||
promptTemplatesDescription: "在 AI 助手中選擇一或多個範本,注入場景化的約定規範。",
|
||||
promptTemplateNew: "新增範本",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MAX_AGENT_TURNS_DEFAULT, MAX_AGENT_TURNS_MAX, MAX_AGENT_TURNS_MIN, maxAgentTurnsOutOfRange, normalizeMaxAgentTurns } from "@/lib/ai/maxAgentTurns";
|
||||
|
||||
const settingsDialogSource = readFileSync(new URL("../../../components/editor/EditorSettingsDialog.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("maxAgentTurnsOutOfRange", () => {
|
||||
it("accepts values within [min, max]", () => {
|
||||
expect(maxAgentTurnsOutOfRange(30)).toBe(false);
|
||||
expect(maxAgentTurnsOutOfRange(MAX_AGENT_TURNS_MIN)).toBe(false);
|
||||
expect(maxAgentTurnsOutOfRange(MAX_AGENT_TURNS_MAX)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects values outside [min, max]", () => {
|
||||
expect(maxAgentTurnsOutOfRange(MAX_AGENT_TURNS_MIN - 1)).toBe(true);
|
||||
expect(maxAgentTurnsOutOfRange(MAX_AGENT_TURNS_MAX + 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves loading state to the dialog instead of treating it as a range error", () => {
|
||||
expect(maxAgentTurnsOutOfRange(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("flags +Infinity as out of range (regression: a bare Number.isFinite guard short-circuits this to false)", () => {
|
||||
// A user can type "1e400" into <input type="number">, which the browser accepts
|
||||
// and Number() parses to Infinity. The check must not short-circuit on it.
|
||||
expect(maxAgentTurnsOutOfRange(Number.POSITIVE_INFINITY)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent turn limit loading", () => {
|
||||
it("starts loading independently and blocks saves until the persisted value arrives", () => {
|
||||
const aiTabStart = settingsDialogSource.indexOf('if (tab === "ai") {');
|
||||
const aiTabBranch = settingsDialogSource.slice(aiTabStart, settingsDialogSource.indexOf('if (tab === "about"', aiTabStart));
|
||||
expect(aiTabBranch.indexOf("void loadMaxAgentTurnsSetting()")).toBeLessThan(aiTabBranch.indexOf("await promptTemplateStore.ensureLoaded()"));
|
||||
expect(settingsDialogSource).toContain("if (!maxAgentTurnsLoaded.value) return;");
|
||||
expect(settingsDialogSource).toContain(':disabled="!maxAgentTurnsLoaded || maxAgentTurnsSaving"');
|
||||
expect(settingsDialogSource).toContain(':disabled="!maxAgentTurnsLoaded || maxAgentTurnsSaving || maxAgentTurnsOutOfRange(editMaxAgentTurns)"');
|
||||
});
|
||||
|
||||
it("keeps failed loads retryable instead of replacing them with the default", () => {
|
||||
const loadFunction = settingsDialogSource.slice(settingsDialogSource.indexOf("async function loadMaxAgentTurnsSetting()"), settingsDialogSource.indexOf("async function saveMaxAgentTurnsSetting()"));
|
||||
expect(loadFunction).toContain("maxAgentTurnsLoadError.value = e?.message || String(e)");
|
||||
expect(loadFunction).not.toContain("editMaxAgentTurns.value = MAX_AGENT_TURNS_DEFAULT");
|
||||
expect(settingsDialogSource).toContain('v-if="maxAgentTurnsLoadError"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeMaxAgentTurns", () => {
|
||||
it("preserves and rounds finite values within the supported range", () => {
|
||||
expect(normalizeMaxAgentTurns(100)).toBe(100);
|
||||
expect(normalizeMaxAgentTurns(30.6)).toBe(31);
|
||||
});
|
||||
|
||||
it("clamps finite values outside the supported range", () => {
|
||||
expect(normalizeMaxAgentTurns(MAX_AGENT_TURNS_MIN - 1)).toBe(MAX_AGENT_TURNS_MIN);
|
||||
expect(normalizeMaxAgentTurns(MAX_AGENT_TURNS_MAX + 1)).toBe(MAX_AGENT_TURNS_MAX);
|
||||
});
|
||||
|
||||
it("uses the default for empty and non-finite input", () => {
|
||||
expect(normalizeMaxAgentTurns(undefined)).toBe(MAX_AGENT_TURNS_DEFAULT);
|
||||
expect(normalizeMaxAgentTurns(Number.NaN)).toBe(MAX_AGENT_TURNS_DEFAULT);
|
||||
expect(normalizeMaxAgentTurns(Number.POSITIVE_INFINITY)).toBe(MAX_AGENT_TURNS_DEFAULT);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
// Mirrors DEFAULT/MIN/MAX_MAX_AGENT_TURNS in crates/dbx-core/src/agent_loop.rs.
|
||||
// The backend clamp remains the source of truth for persisted values.
|
||||
export const MAX_AGENT_TURNS_DEFAULT = 30;
|
||||
export const MAX_AGENT_TURNS_MIN = 5;
|
||||
export const MAX_AGENT_TURNS_MAX = 500;
|
||||
|
||||
export function maxAgentTurnsOutOfRange(value: number | undefined): boolean {
|
||||
return typeof value === "number" && (value < MAX_AGENT_TURNS_MIN || value > MAX_AGENT_TURNS_MAX);
|
||||
}
|
||||
|
||||
export function normalizeMaxAgentTurns(value: number | undefined): number {
|
||||
const rounded = typeof value === "number" && Number.isFinite(value) ? Math.round(value) : MAX_AGENT_TURNS_DEFAULT;
|
||||
return Math.min(MAX_AGENT_TURNS_MAX, Math.max(MAX_AGENT_TURNS_MIN, rounded));
|
||||
}
|
||||
@@ -255,6 +255,8 @@ export const loadDesktopSettings = forward("loadDesktopSettings");
|
||||
export const saveDesktopSettings = forward("saveDesktopSettings");
|
||||
export const loadMcpGlobalPolicy = forward("loadMcpGlobalPolicy");
|
||||
export const saveMcpGlobalPolicy = forward("saveMcpGlobalPolicy");
|
||||
export const loadMaxAgentTurns = forward("loadMaxAgentTurns");
|
||||
export const saveMaxAgentTurns = forward("saveMaxAgentTurns");
|
||||
export const completeAppClose = forward("completeAppClose");
|
||||
export const requestAppClose = forward("requestAppClose");
|
||||
export const setDriverStoreDir = forward("setDriverStoreDir");
|
||||
|
||||
@@ -1202,6 +1202,19 @@ export async function saveMcpGlobalPolicy(policy: Omit<McpGlobalPolicy, "configu
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
export async function loadMaxAgentTurns(): Promise<number> {
|
||||
return get("/api/app-settings/max-agent-turns");
|
||||
}
|
||||
|
||||
export async function saveMaxAgentTurns(maxAgentTurns: number): Promise<void> {
|
||||
const res = await fetch(apiUrl("/api/app-settings/max-agent-turns"), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ maxAgentTurns }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
export interface OpenTabsStatePayload {
|
||||
tabs: unknown[];
|
||||
activeTabId: string | null;
|
||||
|
||||
@@ -464,6 +464,14 @@ export async function saveMcpGlobalPolicy(policy: Omit<McpGlobalPolicy, "configu
|
||||
return invoke("save_mcp_global_policy", { policy });
|
||||
}
|
||||
|
||||
export async function loadMaxAgentTurns(): Promise<number> {
|
||||
return invoke("load_max_agent_turns");
|
||||
}
|
||||
|
||||
export async function saveMaxAgentTurns(maxAgentTurns: number): Promise<void> {
|
||||
return invoke("save_max_agent_turns", { maxAgentTurns });
|
||||
}
|
||||
|
||||
export interface OpenTabsStatePayload {
|
||||
tabs: unknown[];
|
||||
activeTabId: string | null;
|
||||
|
||||
Reference in new issue
Block a user