diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index e2775c85f..56b1966e7 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -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(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(null); @@ -4768,6 +4811,28 @@ onUnmounted(cleanupPreviewEditor); + +
+ +
+

{{ t("ai.maxAgentTurns") }}

+

{{ t("ai.maxAgentTurnsDescription") }}

+
+
+ + + {{ t("ai.maxAgentTurnsRange", { min: MAX_AGENT_TURNS_MIN, max: MAX_AGENT_TURNS_MAX, default: MAX_AGENT_TURNS_DEFAULT }) }} + +
+ + +
+
+
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 8580aac48..e16e8cb80 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 5f545b662..3c706437a 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index cec3a9a77..084450786 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index d3a6b5235..65e6e4d62 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -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: "新規テンプレート", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index b7a252b1d..a5ca1309d 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 68f7a36f9..fbdcefe05 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -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: "新建模板", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 2e0af0eac..5daa5b50c 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -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: "新增範本", diff --git a/apps/desktop/src/lib/__tests__/ai/maxAgentTurnsRange.spec.ts b/apps/desktop/src/lib/__tests__/ai/maxAgentTurnsRange.spec.ts new file mode 100644 index 000000000..6d3ef5839 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/ai/maxAgentTurnsRange.spec.ts @@ -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 , 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); + }); +}); diff --git a/apps/desktop/src/lib/ai/maxAgentTurns.ts b/apps/desktop/src/lib/ai/maxAgentTurns.ts new file mode 100644 index 000000000..f02275bb9 --- /dev/null +++ b/apps/desktop/src/lib/ai/maxAgentTurns.ts @@ -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)); +} diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 855d8cbf9..54efda3c0 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -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"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 65b491a87..173a2af63 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -1202,6 +1202,19 @@ export async function saveMcpGlobalPolicy(policy: Omit { + return get("/api/app-settings/max-agent-turns"); +} + +export async function saveMaxAgentTurns(maxAgentTurns: number): Promise { + 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; diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 29c358b6b..0bc4fdc89 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -464,6 +464,14 @@ export async function saveMcpGlobalPolicy(policy: Omit { + return invoke("load_max_agent_turns"); +} + +export async function saveMaxAgentTurns(maxAgentTurns: number): Promise { + return invoke("save_max_agent_turns", { maxAgentTurns }); +} + export interface OpenTabsStatePayload { tabs: unknown[]; activeTabId: string | null; diff --git a/crates/dbx-core/src/agent_loop.rs b/crates/dbx-core/src/agent_loop.rs index f6e957b1d..107ff8af1 100644 --- a/crates/dbx-core/src/agent_loop.rs +++ b/crates/dbx-core/src/agent_loop.rs @@ -14,8 +14,21 @@ use crate::connection::AppState; use crate::models::connection::DatabaseType; use crate::token_usage::TokenUsage; -/// Maximum number of agent loop turns to prevent infinite loops. -const MAX_AGENT_TURNS: u32 = 30; +/// Default number of agent loop turns to prevent infinite loops. +/// Users can raise the limit in Settings → AI; it is clamped to +/// [`MIN_MAX_AGENT_TURNS`, `MAX_MAX_AGENT_TURNS`] so it can never be unlimited. +/// +/// These values are mirrored in apps/desktop/src/components/editor/EditorSettingsDialog.vue +/// (MAX_AGENT_TURNS_DEFAULT/MIN/MAX) for client-side input validation — this module's +/// `clamp_max_agent_turns` remains the actual source of truth, applied on every save/load. +pub const DEFAULT_MAX_AGENT_TURNS: u32 = 30; +pub const MIN_MAX_AGENT_TURNS: u32 = 5; +pub const MAX_MAX_AGENT_TURNS: u32 = 500; + +/// Clamp a user-provided agent turn limit into the supported range. +pub fn clamp_max_agent_turns(value: u32) -> u32 { + value.clamp(MIN_MAX_AGENT_TURNS, MAX_MAX_AGENT_TURNS) +} const MAX_TOOL_RESULT_CONTEXT_CHARS: usize = 12_000; const TOOL_RESULT_HEAD_CHARS: usize = 4_000; const TOOL_RESULT_TAIL_CHARS: usize = 4_000; @@ -66,6 +79,9 @@ pub struct AgentLoopContext { pub db_type: DatabaseType, pub cli_mcp_server_command: Option, pub sql_permissions: agent_tools::AgentSqlPermissions, + /// Turn limit for this run, already clamped by the settings layer. + /// Callers that have no user setting should pass [`DEFAULT_MAX_AGENT_TURNS`]. + pub max_agent_turns: u32, } /// Check if the provider supports function calling / tool use. @@ -158,7 +174,9 @@ pub async fn run_agent_loop( let mut total_usage = TokenUsage::default(); let mut contract_repair_attempts = 0; - for turn in 0..MAX_AGENT_TURNS { + let max_agent_turns = clamp_max_agent_turns(agent_ctx.max_agent_turns); + + for turn in 0..max_agent_turns { // Check for cancellation before each turn if cancelled.notified().now_or_never().is_some() { loop_exit = LoopExit::Cancelled; @@ -434,10 +452,10 @@ pub async fn run_agent_loop( } LoopExit::Exhausted => { let message = if final_text.trim().is_empty() { - format!("Agent reached the {MAX_AGENT_TURNS}-turn safety limit before producing output. Send Continue to let the agent keep working.") + format!("Agent reached the {max_agent_turns}-turn safety limit before producing output. Send Continue to let the agent keep working, or raise the limit in Settings → AI.") } else { format!( - "\n\nAgent reached the {MAX_AGENT_TURNS}-turn safety limit before a final answer. The partial output above was preserved; send Continue to let the agent keep working." + "\n\nAgent reached the {max_agent_turns}-turn safety limit before a final answer. The partial output above was preserved; send Continue to let the agent keep working, or raise the limit in Settings → AI." ) }; on_event(AgentEvent::TextDelta { delta: message.clone() }); @@ -1171,6 +1189,15 @@ fn summarize_message_content(content: &str) -> String { mod tests { use super::*; + #[test] + fn clamp_max_agent_turns_enforces_bounds() { + assert_eq!(clamp_max_agent_turns(0), MIN_MAX_AGENT_TURNS); + assert_eq!(clamp_max_agent_turns(MIN_MAX_AGENT_TURNS), MIN_MAX_AGENT_TURNS); + assert_eq!(clamp_max_agent_turns(DEFAULT_MAX_AGENT_TURNS), DEFAULT_MAX_AGENT_TURNS); + assert_eq!(clamp_max_agent_turns(200), 200); + assert_eq!(clamp_max_agent_turns(u32::MAX), MAX_MAX_AGENT_TURNS); + } + fn generate_contract(user_request: &str, mode: &str) -> AiTaskContract { AiTaskContract { action: Some("generate".to_string()), diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index b3d59f179..727d378e6 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -1727,6 +1727,26 @@ impl Storage { settings.remove("webdav_sync_secrets_passphrase"); self.save_app_settings_json(&settings).await } + + pub async fn save_max_agent_turns(&self, max_agent_turns: u32) -> Result<(), String> { + let mut settings = self.load_app_settings_json().await?; + settings.insert( + "max_agent_turns".to_string(), + serde_json::Value::Number(serde_json::Number::from(crate::agent_loop::clamp_max_agent_turns( + max_agent_turns, + ))), + ); + self.save_app_settings_json(&settings).await + } + + pub async fn load_max_agent_turns(&self) -> Result { + let settings = self.load_app_settings_json().await?; + Ok(settings + .get("max_agent_turns") + .and_then(serde_json::Value::as_u64) + .map(|value| crate::agent_loop::clamp_max_agent_turns(value.min(u32::MAX as u64) as u32)) + .unwrap_or(crate::agent_loop::DEFAULT_MAX_AGENT_TURNS)) + } } // AI Conversations @@ -4365,6 +4385,23 @@ mod tests { assert_eq!(storage.load_desktop_settings().await.unwrap().duckdb_worker_max_processes, 8); } + #[tokio::test] + async fn max_agent_turns_defaults_and_persists_clamped() { + let path = temp_db_path("max-agent-turns"); + let storage = Storage::open(&path).await.unwrap(); + + assert_eq!(storage.load_max_agent_turns().await.unwrap(), crate::agent_loop::DEFAULT_MAX_AGENT_TURNS); + + storage.save_max_agent_turns(100).await.unwrap(); + assert_eq!(storage.load_max_agent_turns().await.unwrap(), 100); + + // Out-of-range values are clamped on save so raw DB edits cannot disable the safety limit. + storage.save_max_agent_turns(0).await.unwrap(); + assert_eq!(storage.load_max_agent_turns().await.unwrap(), crate::agent_loop::MIN_MAX_AGENT_TURNS); + storage.save_max_agent_turns(u32::MAX).await.unwrap(); + assert_eq!(storage.load_max_agent_turns().await.unwrap(), crate::agent_loop::MAX_MAX_AGENT_TURNS); + } + #[tokio::test] async fn password_hash_preserves_existing_desktop_settings() { let path = temp_db_path("password-preserve-desktop-settings"); diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 855057ac9..4fa55fdc4 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -603,6 +603,10 @@ async fn main() { "/app-settings/mcp-policy", get(routes::app_settings::load_mcp_global_policy).put(routes::app_settings::save_mcp_global_policy), ) + .route( + "/app-settings/max-agent-turns", + get(routes::app_settings::load_max_agent_turns).put(routes::app_settings::save_max_agent_turns), + ) .route("/app-settings/config/decrypt", post(routes::app_settings::decrypt_config)) // Cloud sync .route("/cloud-sync/webdav/test", post(routes::cloud_sync::webdav_sync_test)) diff --git a/crates/dbx-web/src/routes/ai.rs b/crates/dbx-web/src/routes/ai.rs index 5a3050d36..2dcad3e28 100644 --- a/crates/dbx-web/src/routes/ai.rs +++ b/crates/dbx-web/src/routes/ai.rs @@ -332,6 +332,10 @@ pub async fn ai_agent_stream( .get(&body.connection_id) .is_some_and(|config| dbx_core::production_safety::is_production_database(config, &body.database)); + let max_agent_turns = state.app.storage.load_max_agent_turns().await.unwrap_or_else(|err| { + log::warn!("Failed to load max_agent_turns setting, using default: {err}"); + dbx_core::agent_loop::DEFAULT_MAX_AGENT_TURNS + }); let agent_ctx = AgentLoopContext { state: state.app.clone(), connection_id: body.connection_id, @@ -342,6 +346,7 @@ pub async fn ai_agent_stream( allow_writes: !production_database && body.allow_write_sql, allow_dangerous: !production_database && body.allow_write_sql, }, + max_agent_turns, }; let sid = session_id.clone(); diff --git a/crates/dbx-web/src/routes/app_settings.rs b/crates/dbx-web/src/routes/app_settings.rs index 9124479a6..cc4aff708 100644 --- a/crates/dbx-web/src/routes/app_settings.rs +++ b/crates/dbx-web/src/routes/app_settings.rs @@ -65,6 +65,24 @@ pub async fn save_mcp_global_policy( Ok(Json(())) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveMaxAgentTurnsRequest { + pub max_agent_turns: u32, +} + +pub async fn load_max_agent_turns(State(state): State>) -> Result, AppError> { + state.app.storage.load_max_agent_turns().await.map(Json).map_err(AppError::from) +} + +pub async fn save_max_agent_turns( + State(state): State>, + Json(body): Json, +) -> Result, AppError> { + state.app.storage.save_max_agent_turns(body.max_agent_turns).await.map_err(AppError::from)?; + Ok(Json(())) +} + pub async fn decrypt_config(Json(body): Json) -> Result, AppError> { decrypt_config_payload(&body.payload, &body.passphrase).map(Json).map_err(AppError::from) } diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index 32a0d974e..d814c6773 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -106,6 +106,10 @@ pub async fn ai_agent_stream( .await .get(&connection_id) .is_some_and(|config| dbx_core::production_safety::is_production_database(config, &database)); + let max_agent_turns = state.storage.load_max_agent_turns().await.unwrap_or_else(|err| { + log::warn!("Failed to load max_agent_turns setting, using default: {err}"); + dbx_core::agent_loop::DEFAULT_MAX_AGENT_TURNS + }); let agent_ctx = AgentLoopContext { state: state.inner().clone(), connection_id, @@ -117,6 +121,7 @@ pub async fn ai_agent_stream( allow_writes: !production_database && allow_write_sql.unwrap_or(false), allow_dangerous: !production_database && allow_write_sql.unwrap_or(false), }, + max_agent_turns, }; let is_agent_mode = mode.as_deref() == Some("agent"); diff --git a/src-tauri/src/commands/app_settings.rs b/src-tauri/src/commands/app_settings.rs index 0b76e808d..40759b5e4 100644 --- a/src-tauri/src/commands/app_settings.rs +++ b/src-tauri/src/commands/app_settings.rs @@ -42,6 +42,16 @@ pub async fn save_desktop_settings( Ok(()) } +#[tauri::command] +pub async fn load_max_agent_turns(state: State<'_, Arc>) -> Result { + state.storage.load_max_agent_turns().await +} + +#[tauri::command] +pub async fn save_max_agent_turns(state: State<'_, Arc>, max_agent_turns: u32) -> Result<(), String> { + state.storage.save_max_agent_turns(max_agent_turns).await +} + #[tauri::command] pub async fn complete_app_close(app: AppHandle, window: Window, action: String) -> Result<(), String> { match action.as_str() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 246c3bef8..87ce73124 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1080,6 +1080,8 @@ pub fn run() { commands::prompt_template::set_ai_global_custom_instructions, commands::app_settings::load_desktop_settings, commands::app_settings::save_desktop_settings, + commands::app_settings::load_max_agent_turns, + commands::app_settings::save_max_agent_turns, commands::app_settings::complete_app_close, commands::app_settings::mark_frontend_ready, commands::app_settings::request_app_close_from_window_controls,