feat(ai): make agent turn limit configurable

This commit is contained in:
Abeautifulsnow 2026-07-23 13:57:06 +08:00 committed by GitHub
parent 21e9578f78
commit 9253203d99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 307 additions and 5 deletions

View File

@ -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 />

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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: "新規テンプレート",

View File

@ -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",

View File

@ -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: "新建模板",

View File

@ -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: "新增範本",

View File

@ -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);
});
});

View File

@ -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));
}

View File

@ -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");

View File

@ -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;

View File

@ -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;

View File

@ -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<CliAgentCommandSpec>,
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()),

View File

@ -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<u32, String> {
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");

View File

@ -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))

View File

@ -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();

View File

@ -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<Arc<WebState>>) -> Result<Json<u32>, 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<Arc<WebState>>,
Json(body): Json<SaveMaxAgentTurnsRequest>,
) -> Result<Json<()>, 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<DecryptConfigRequest>) -> Result<Json<String>, AppError> {
decrypt_config_payload(&body.payload, &body.passphrase).map(Json).map_err(AppError::from)
}

View File

@ -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");

View File

@ -42,6 +42,16 @@ pub async fn save_desktop_settings(
Ok(())
}
#[tauri::command]
pub async fn load_max_agent_turns(state: State<'_, Arc<AppState>>) -> Result<u32, String> {
state.storage.load_max_agent_turns().await
}
#[tauri::command]
pub async fn save_max_agent_turns(state: State<'_, Arc<AppState>>, 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() {

View File

@ -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,