feat(ai): make agent turn limit configurable
This commit is contained in:
parent
21e9578f78
commit
9253203d99
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue