feat(ai): make agent turn limit configurable

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-07-23 13:57:06 +08:00
1 parent 21e9578f78
commit 9253203d99
21 files changed
+307 -5

No files matched your search

+32 -5
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()),
+37
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");