Skip to content

Commit 5b596d2

Browse files
authored
Merge pull request #27 from PotLock/fix/agent-iteration-limit
fix(agent): increase tool iteration limit from 10 to 25 and add budget awareness
2 parents 34d9582 + 868aa22 commit 5b596d2

12 files changed

Lines changed: 91 additions & 28 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/agent/loop_.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ const STREAM_CHUNK_MIN_CHARS: usize = 80;
2525

2626
/// Default maximum agentic tool-use iterations per user message to prevent runaway loops.
2727
/// Used as a safe fallback when `max_tool_iterations` is unset or configured as zero.
28-
const DEFAULT_MAX_TOOL_ITERATIONS: usize = 10;
28+
/// Increased from 10 to 25 to accommodate complex multi-step tasks while maintaining
29+
/// safety bounds. See issue #26 for context.
30+
const DEFAULT_MAX_TOOL_ITERATIONS: usize = 25;
2931

3032
/// Minimum user-message length (in chars) for auto-save to memory.
3133
/// Matches the channel-side constant in `channels/mod.rs`.
@@ -2739,6 +2741,23 @@ pub(crate) fn build_tool_instructions(tools_registry: &[Box<dyn Tool>]) -> Strin
27392741
instructions
27402742
}
27412743

2744+
/// Build the tool budget awareness section for the system prompt.
2745+
/// This helps the agent plan efficiently based on the maximum allowed iterations.
2746+
pub(crate) fn build_tool_budget_prompt(max_iterations: usize) -> String {
2747+
// Calculate dynamic tier ranges based on max_iterations
2748+
let simple_min = std::cmp::max(1, (max_iterations as f32 * 0.12).round() as usize);
2749+
let simple_max = std::cmp::max(2, (max_iterations as f32 * 0.2).round() as usize);
2750+
let medium_min = std::cmp::max(3, (max_iterations as f32 * 0.32).round() as usize);
2751+
let medium_max = std::cmp::max(5, (max_iterations as f32 * 0.48).round() as usize);
2752+
let complex_min = std::cmp::max(8, (max_iterations as f32 * 0.6).round() as usize);
2753+
let complex_max = std::cmp::max(12, (max_iterations as f32 * 0.8).round() as usize);
2754+
2755+
format!(
2756+
"\n## Tool Use Budget\n\nYou have a maximum of {} tool-use iterations to complete this task. Plan efficiently:\n\n- **Complex tasks** (build projects, multiple files): ~{}-{} iterations\n- **Medium tasks** (modify existing code): ~{}-{} iterations\n- **Simple tasks** (read files, explain): ~{}-{} iterations\n\nIf approaching the limit, prioritize:\n1. Complete the critical path first (create → build → verify)\n2. Skip verification steps if already confident\n3. Provide partial results with explanation rather than failing\n\n",
2757+
max_iterations, complex_min, complex_max, medium_min, medium_max, simple_min, simple_max
2758+
)
2759+
}
2760+
27422761
// ── CLI Entrypoint ───────────────────────────────────────────────────────
27432762
// Wires up all subsystems (observer, runtime, security, memory, tools,
27442763
// provider, hardware RAG, peripherals) and enters either single-shot or
@@ -3019,6 +3038,14 @@ pub async fn run(
30193038
system_prompt.push_str(&build_tool_instructions(&tools_registry));
30203039
}
30213040

3041+
// Add iteration budget awareness to system prompt
3042+
let max_iterations = if config.agent.max_tool_iterations == 0 {
3043+
DEFAULT_MAX_TOOL_ITERATIONS
3044+
} else {
3045+
config.agent.max_tool_iterations
3046+
};
3047+
system_prompt.push_str(&build_tool_budget_prompt(max_iterations));
3048+
30223049
// ── Approval manager (supervised mode) ───────────────────────
30233050
let approval_manager = if interactive {
30243051
Some(ApprovalManager::from_config(&config.autonomy))
@@ -3433,6 +3460,14 @@ pub async fn process_message(config: Config, message: &str) -> Result<String> {
34333460
system_prompt.push_str(&build_tool_instructions(&tools_registry));
34343461
}
34353462

3463+
// Add iteration budget awareness to system prompt
3464+
let max_iterations = if config.agent.max_tool_iterations == 0 {
3465+
DEFAULT_MAX_TOOL_ITERATIONS
3466+
} else {
3467+
config.agent.max_tool_iterations
3468+
};
3469+
system_prompt.push_str(&build_tool_budget_prompt(max_iterations));
3470+
34363471
let mem_context = build_context(mem.as_ref(), message, config.memory.min_relevance_score).await;
34373472
let rag_limit = if config.agent.compact_context { 2 } else { 5 };
34383473
let hw_context = hardware_rag

src/channels/mod.rs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4110,7 +4110,7 @@ BTC is currently around $65,000 based on latest tool output."#
41104110
model: Arc::new("test-model".to_string()),
41114111
temperature: 0.0,
41124112
auto_save_memory: false,
4113-
max_tool_iterations: 10,
4113+
max_tool_iterations: 25,
41144114
min_relevance_score: 0.0,
41154115
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
41164116
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@@ -4169,7 +4169,7 @@ BTC is currently around $65,000 based on latest tool output."#
41694169
model: Arc::new("test-model".to_string()),
41704170
temperature: 0.0,
41714171
auto_save_memory: false,
4172-
max_tool_iterations: 10,
4172+
max_tool_iterations: 25,
41734173
min_relevance_score: 0.0,
41744174
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
41754175
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@@ -4242,7 +4242,7 @@ BTC is currently around $65,000 based on latest tool output."#
42424242
model: Arc::new("test-model".to_string()),
42434243
temperature: 0.0,
42444244
auto_save_memory: false,
4245-
max_tool_iterations: 10,
4245+
max_tool_iterations: 25,
42464246
min_relevance_score: 0.0,
42474247
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
42484248
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@@ -4301,7 +4301,7 @@ BTC is currently around $65,000 based on latest tool output."#
43014301
model: Arc::new("test-model".to_string()),
43024302
temperature: 0.0,
43034303
auto_save_memory: false,
4304-
max_tool_iterations: 10,
4304+
max_tool_iterations: 25,
43054305
min_relevance_score: 0.0,
43064306
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
43074307
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@@ -4917,7 +4917,7 @@ BTC is currently around $65,000 based on latest tool output."#
49174917
model: Arc::new("test-model".to_string()),
49184918
temperature: 0.0,
49194919
auto_save_memory: false,
4920-
max_tool_iterations: 10,
4920+
max_tool_iterations: 25,
49214921
min_relevance_score: 0.0,
49224922
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
49234923
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@@ -4997,7 +4997,7 @@ BTC is currently around $65,000 based on latest tool output."#
49974997
model: Arc::new("test-model".to_string()),
49984998
temperature: 0.0,
49994999
auto_save_memory: false,
5000-
max_tool_iterations: 10,
5000+
max_tool_iterations: 25,
50015001
min_relevance_score: 0.0,
50025002
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
50035003
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@@ -5089,7 +5089,7 @@ BTC is currently around $65,000 based on latest tool output."#
50895089
model: Arc::new("test-model".to_string()),
50905090
temperature: 0.0,
50915091
auto_save_memory: false,
5092-
max_tool_iterations: 10,
5092+
max_tool_iterations: 25,
50935093
min_relevance_score: 0.0,
50945094
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
50955095
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@@ -5163,7 +5163,7 @@ BTC is currently around $65,000 based on latest tool output."#
51635163
model: Arc::new("test-model".to_string()),
51645164
temperature: 0.0,
51655165
auto_save_memory: false,
5166-
max_tool_iterations: 10,
5166+
max_tool_iterations: 25,
51675167
min_relevance_score: 0.0,
51685168
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
51695169
provider_cache: Arc::new(Mutex::new(HashMap::new())),
@@ -5222,7 +5222,7 @@ BTC is currently around $65,000 based on latest tool output."#
52225222
model: Arc::new("test-model".to_string()),
52235223
temperature: 0.0,
52245224
auto_save_memory: false,
5225-
max_tool_iterations: 10,
5225+
max_tool_iterations: 25,
52265226
min_relevance_score: 0.0,
52275227
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
52285228
provider_cache: Arc::new(Mutex::new(HashMap::new())),

src/config/schema.rs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -542,8 +542,21 @@ pub struct AgentConfig {
542542
/// When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models.
543543
#[serde(default)]
544544
pub compact_context: bool,
545-
/// Maximum tool-call loop turns per user message. Default: `10`.
546-
/// Setting to `0` falls back to the safe default of `10`.
545+
/// Maximum tool-call loop turns per user message. Default: `25`.
546+
/// Setting to `0` falls back to the safe default of `25`.
547+
///
548+
/// ## Compatibility Note
549+
/// This change only alters the default value. Existing configurations with
550+
/// explicit `max_tool_iterations` values are respected and will continue to work.
551+
///
552+
/// ## Rollback / Migration
553+
/// To restore the previous behavior (limit of 10 iterations), explicitly set:
554+
/// ```toml
555+
/// [agent]
556+
/// max_tool_iterations = 10
557+
/// ```
558+
///
559+
/// See issue #26 for context on why the default was increased.
547560
#[serde(default = "default_agent_max_tool_iterations")]
548561
pub max_tool_iterations: usize,
549562
/// Maximum conversation history messages retained per session. Default: `50`.
@@ -558,7 +571,7 @@ pub struct AgentConfig {
558571
}
559572

560573
fn default_agent_max_tool_iterations() -> usize {
561-
10
574+
25
562575
}
563576

564577
fn default_agent_max_history_messages() -> usize {
@@ -5028,7 +5041,7 @@ reasoning_enabled = false
50285041
async fn agent_config_defaults() {
50295042
let cfg = AgentConfig::default();
50305043
assert!(!cfg.compact_context);
5031-
assert_eq!(cfg.max_tool_iterations, 10);
5044+
assert_eq!(cfg.max_tool_iterations, 25);
50325045
assert_eq!(cfg.max_history_messages, 50);
50335046
assert!(!cfg.parallel_tools);
50345047
assert_eq!(cfg.tool_dispatcher, "auto");

src/sandbox/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use std::fmt;
1515

1616
/// Package manager types supported by the sandbox, ordered by priority.
1717
/// Priority: pnpm > yarn > npm
18-
///
18+
///
1919
/// Note: Default is Npm as the safe fallback, but detection prioritizes pnpm/yarn.
2020
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2121
pub enum PackageManager {

src/tools/github_ops.rs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,21 @@ fn validate_labels(labels: &[String]) -> Result<(), String> {
110110
fn sanitize_labels(labels: &[String]) -> Vec<String> {
111111
// Known valid scope labels that don't contain spaces
112112
const VALID_SCOPE_LABELS: &[&str] = &[
113-
"provider", "channel", "tool", "gateway", "memory", "runtime",
114-
"config", "ci", "performance", "ui", "api", "database", "deps",
113+
"provider",
114+
"channel",
115+
"tool",
116+
"gateway",
117+
"memory",
118+
"runtime",
119+
"config",
120+
"ci",
121+
"performance",
122+
"ui",
123+
"api",
124+
"database",
125+
"deps",
115126
];
116-
127+
117128
labels
118129
.iter()
119130
.filter(|label| {

src/tools/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,8 @@ pub use pushover::PushoverTool;
110110
pub use sandbox::{
111111
SandboxCreateTool, SandboxGetPackageManagerTool, SandboxGetPreviewUrlTool,
112112
SandboxGetPublicUrlTool, SandboxKillTool, SandboxListFilesTool, SandboxReadFileTool,
113-
SandboxRestoreSnapshotTool, SandboxRunCommandTool, SandboxSaveSnapshotTool, SandboxWriteFileTool,
113+
SandboxRestoreSnapshotTool, SandboxRunCommandTool, SandboxSaveSnapshotTool,
114+
SandboxWriteFileTool,
114115
};
115116
pub use schedule::ScheduleTool;
116117
#[allow(unused_imports)]

src/tools/sandbox/create.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,10 @@ impl Tool for SandboxCreateTool {
8888
let tip = if pm == crate::sandbox::PackageManager::Npm {
8989
"💡 Tip: Using npm as package manager. Consider installing pnpm for faster installs.".to_string()
9090
} else {
91-
format!("💡 Tip: Use '{install}' for faster installs instead of 'npm install'", install = pm.install_cmd())
91+
format!(
92+
"💡 Tip: Use '{install}' for faster installs instead of 'npm install'",
93+
install = pm.install_cmd()
94+
)
9295
};
9396
Ok(ToolResult {
9497
success: true,

src/tools/sandbox/package_manager.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ impl Tool for SandboxGetPackageManagerTool {
5656

5757
// Actively detect package manager instead of using cached state
5858
let pm = self.client.detect_package_manager().await;
59-
59+
6060
let output = format!(
6161
"Detected package manager: {pm}\n\
6262
Install command: {install}\n\

tests/agent_loop_robustness.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ async fn agent_handles_mixed_tool_success_and_failure() {
315315
// TG4.3: Iteration limit enforcement (#777)
316316
// ═════════════════════════════════════════════════════════════════════════════
317317

318-
/// Agent should not exceed max_tool_iterations (default=10) even with
318+
/// Agent should not exceed max_tool_iterations (default=25) even with
319319
/// a provider that keeps returning tool calls
320320
#[tokio::test]
321321
async fn agent_respects_max_tool_iterations() {

0 commit comments

Comments
 (0)