Skip to content

Commit dd78e5f

Browse files
cursor[bot]cursoragentczxtm
authored
Replace evolution iteration limit with token budget (#272)
* Replace evolution iteration limit with token budget Co-authored-by: cooper <czxtm@users.noreply.github.com> * Fix token progress parser compatibility Co-authored-by: cooper <czxtm@users.noreply.github.com> * Update evolution limit story snapshots Co-authored-by: cooper <czxtm@users.noreply.github.com> * Update widget story snapshots for token budget UI Co-authored-by: cooper <czxtm@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cooper <czxtm@users.noreply.github.com> Co-authored-by: cooper <1325802+czxtm@users.noreply.github.com>
1 parent a3bd7aa commit dd78e5f

21 files changed

Lines changed: 253 additions & 99 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ nixmac evolve "install ripgrep and fd"
193193
# With options
194194
nixmac evolve "enable Touch ID for sudo" \
195195
--config ~/.darwin \
196-
--max-iterations 10 \
196+
--max-token-budget 50000 \
197197
--max-output-tokens 32768 \
198198
--evolve-provider ollama \
199199
--evolve-model qwen3-coder:30b

apps/native/.storybook/mocks/tauri-runtime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const prefs = {
5959
evolveProvider: "openai",
6060
evolveModel: "gpt-5",
6161
maxIterations: 25,
62+
maxTokenBudget: 50_000,
6263
maxBuildAttempts: 3,
6364
maxOutputTokens: 32768,
6465
sendDiagnostics: true,

apps/native/src-tauri/src/cli.rs

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
//! Usage:
44
//! nixmac evolve "your prompt here"
55
//! nixmac evolve "your prompt" --config /path/to/config
6-
//! nixmac evolve "your prompt" --max-iterations 5 --host aarch64-darwin
6+
//! nixmac evolve "your prompt" --max-token-budget 50000 --host aarch64-darwin
77
//!
88
//! NOTE NOTE NOTE: If you pass any CLI arguments corresponding to settings that
99
//! come from the app store, those CLI arguments will override the store values
@@ -25,6 +25,7 @@ pub struct EvolveConfig {
2525
pub config: Option<PathBuf>,
2626
pub max_iterations: Option<usize>,
2727
pub max_output_tokens: Option<usize>,
28+
pub max_token_budget: Option<u32>,
2829
pub evolve_provider: Option<String>,
2930
pub evolve_model: Option<String>,
3031
pub summary_provider: Option<String>,
@@ -55,14 +56,18 @@ pub enum Commands {
5556
#[arg(short, long)]
5657
config: Option<PathBuf>,
5758

58-
/// Maximum iterations for the evolution
59-
#[arg(short, long)]
59+
/// Legacy fallback for providers that do not report token usage
60+
#[arg(short, long, hide = true)]
6061
max_iterations: Option<usize>,
6162

6263
/// Maximum output tokens requested per evolution model call
6364
#[arg(long)]
6465
max_output_tokens: Option<usize>,
6566

67+
/// Maximum provider-reported tokens for the evolution
68+
#[arg(long)]
69+
max_token_budget: Option<u32>,
70+
6671
/// Provider for evolution (e.g., openai, openrouter, ollama)
6772
#[arg(long)]
6873
evolve_provider: Option<String>,
@@ -108,6 +113,7 @@ pub async fn handle_evolve_command(app: &AppHandle, cfg: EvolveConfig) -> Result
108113
config,
109114
max_iterations,
110115
max_output_tokens,
116+
max_token_budget,
111117
evolve_provider,
112118
evolve_model,
113119
summary_provider,
@@ -192,7 +198,7 @@ pub async fn handle_evolve_command(app: &AppHandle, cfg: EvolveConfig) -> Result
192198
None => crate::storage::store::get_summary_model(app).ok().flatten(),
193199
};
194200

195-
// Effective max iterations: prefer CLI value, otherwise read from store (has default)
201+
// Effective legacy iteration fallback: prefer CLI value, otherwise read from store (has default)
196202
let effective_max_iterations: usize = match max_iterations {
197203
Some(v) => v,
198204
None => crate::storage::store::get_max_iterations(app)
@@ -204,7 +210,14 @@ pub async fn handle_evolve_command(app: &AppHandle, cfg: EvolveConfig) -> Result
204210
.unwrap_or(crate::storage::store::DEFAULT_MAX_OUTPUT_TOKENS),
205211
};
206212

207-
// Max iterations
213+
// Effective max token budget: prefer CLI value, otherwise read from store (has default)
214+
let effective_max_token_budget: u32 = match max_token_budget {
215+
Some(v) => v,
216+
None => crate::storage::store::get_max_token_budget(app)
217+
.unwrap_or(crate::storage::store::DEFAULT_MAX_TOKEN_BUDGET),
218+
};
219+
220+
// Legacy max iterations
208221
if let Some(iterations) = max_iterations {
209222
crate::storage::store::set_max_iterations(app, iterations)
210223
.map_err(|e| format!("Failed to set max iterations: {}", e))?;
@@ -215,6 +228,12 @@ pub async fn handle_evolve_command(app: &AppHandle, cfg: EvolveConfig) -> Result
215228
.map_err(|e| format!("Failed to set max output tokens: {}", e))?;
216229
}
217230

231+
// Max token budget
232+
if let Some(token_budget) = max_token_budget {
233+
crate::storage::store::set_max_token_budget(app, token_budget)
234+
.map_err(|e| format!("Failed to set max token budget: {}", e))?;
235+
}
236+
218237
// Host
219238
if let Some(ref host_attr) = host {
220239
crate::storage::store::set_host_attr(app, host_attr)
@@ -273,6 +292,7 @@ pub async fn handle_evolve_command(app: &AppHandle, cfg: EvolveConfig) -> Result
273292
"prompt": prompt,
274293
"maxIterations": effective_max_iterations,
275294
"maxOutputTokens": effective_max_output_tokens,
295+
"maxTokenBudget": effective_max_token_budget,
276296
"evolveProvider": effective_evolve_provider,
277297
"evolveModel": effective_evolve_model,
278298
"summaryProvider": effective_summary_provider,

apps/native/src-tauri/src/commands/ui_prefs.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ pub async fn ui_get_prefs(app: AppHandle) -> Result<shared_types::UiPrefs, Strin
2929

3030
let max_iterations =
3131
Some(store::get_max_iterations(&app).unwrap_or(store::DEFAULT_MAX_ITERATIONS));
32+
let max_token_budget =
33+
Some(store::get_max_token_budget(&app).unwrap_or(store::DEFAULT_MAX_TOKEN_BUDGET));
3234
let max_build_attempts = Some(store::get_max_build_attempts(&app).unwrap_or(5));
3335
let max_output_tokens =
3436
Some(store::get_max_output_tokens(&app).unwrap_or(store::DEFAULT_MAX_OUTPUT_TOKENS));
@@ -90,6 +92,7 @@ pub async fn ui_get_prefs(app: AppHandle) -> Result<shared_types::UiPrefs, Strin
9092
summary_model,
9193

9294
max_iterations,
95+
max_token_budget,
9396
max_build_attempts,
9497
max_output_tokens,
9598

@@ -143,6 +146,10 @@ pub async fn ui_set_prefs(
143146
store::set_max_iterations(&app, max_iterations)
144147
.map_err(|e| capture_err("ui_set_prefs", e))?;
145148
}
149+
if let Some(max_token_budget) = prefs.max_token_budget {
150+
store::set_max_token_budget(&app, max_token_budget)
151+
.map_err(|e| capture_err("ui_set_prefs", e))?;
152+
}
146153
if let Some(max_build_attempts) = prefs.max_build_attempts {
147154
store::set_max_build_attempts(&app, max_build_attempts)
148155
.map_err(|e| capture_err("ui_set_prefs", e))?;

apps/native/src-tauri/src/evolve/mod.rs

Lines changed: 80 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -327,9 +327,9 @@ fn log_api_error(
327327
const DEFAULT_MODEL: &str = "anthropic/claude-sonnet-4";
328328
const DEFAULT_OLLAMA_API_BASE: &str = "http://localhost:11434";
329329

330-
// Percentage of max_iterations after which we require at least one edit/build_check.
331-
// Example: with max_iterations=50 and this set to 75, threshold is 37 iterations.
332-
const MAX_ITERATIONS_BEFORE_EDIT_PERCENT: usize = 75;
330+
// Percentage of the token budget after which we require at least one edit/build_check.
331+
// Example: with maxTokenBudget=50,000 and this set to 75, threshold is 37,500 tokens.
332+
const MAX_TOKEN_BUDGET_BEFORE_EDIT_PERCENT: u32 = 75;
333333

334334
// Applied separately to stdout and stderr. So when thinking about tokens,
335335
// the effective output limit could be up to double this if both are long.
@@ -690,22 +690,26 @@ pub async fn generate_evolution<R: Runtime>(
690690

691691
// Read configurable limits from store (hot-reloaded on every run).
692692
let config::EvolutionLimits {
693-
max_iterations,
694693
max_build_attempts,
694+
..
695695
} = config::EvolutionLimits::load(app)
696696
.inspect_err(|e| warn!("EvolutionLimits::load failed ({e}); using defaults"))
697697
.unwrap_or_default();
698-
let max_iterations_before_edit = std::cmp::max(
698+
let legacy_max_iterations =
699+
store::get_max_iterations(app).unwrap_or(store::DEFAULT_MAX_ITERATIONS);
700+
let max_token_budget =
701+
store::get_max_token_budget(app).unwrap_or(store::DEFAULT_MAX_TOKEN_BUDGET);
702+
let max_tokens_before_edit = std::cmp::max(
699703
1,
700-
(max_iterations * MAX_ITERATIONS_BEFORE_EDIT_PERCENT) / 100,
704+
(max_token_budget * MAX_TOKEN_BUDGET_BEFORE_EDIT_PERCENT) / 100,
701705
);
702706
info!(
703-
"Limits: max_iterations={}, max_iterations_before_edit={} ({}%), max_build_attempts={}, max_output_tokens={}",
704-
max_iterations,
705-
max_iterations_before_edit,
706-
MAX_ITERATIONS_BEFORE_EDIT_PERCENT,
707+
"Limits: max_token_budget={}, max_tokens_before_edit={} ({}%), max_build_attempts={}, legacy_max_iterations={}",
708+
max_token_budget,
709+
max_tokens_before_edit,
710+
MAX_TOKEN_BUDGET_BEFORE_EDIT_PERCENT,
707711
max_build_attempts,
708-
max_output_tokens
712+
legacy_max_iterations,
709713
);
710714

711715
let tools = create_tools(banned_tools);
@@ -719,6 +723,7 @@ pub async fn generate_evolution<R: Runtime>(
719723
let mut build_attempts: usize = 0;
720724
let mut build_verified = false;
721725
let mut total_tokens: u32 = 0;
726+
let mut token_usage_observed = false;
722727
let chat_memory_store = session_chat_memory_store();
723728

724729
// Restore only persisted conversational history (user/assistant, NOT tool)
@@ -923,14 +928,21 @@ pub async fn generate_evolution<R: Runtime>(
923928

924929
// Track token usage
925930
if let Some(usage) = &response.usage {
926-
total_tokens += usage.total;
931+
token_usage_observed = true;
932+
total_tokens = total_tokens.saturating_add(usage.total);
927933
info!(
928-
"📊 Tokens | this_call: {} (in={}, out={}) | total_session: {}",
929-
usage.total, usage.input, usage.output, total_tokens
934+
"📊 Tokens | this_call: {} (in={}, out={}) | total_session: {}/{}",
935+
usage.total, usage.input, usage.output, total_tokens, max_token_budget
930936
);
931937
emit_evolve_event(
932938
app,
933-
EvolveEvent::api_response(start_time, iteration, usage.total),
939+
EvolveEvent::api_response(
940+
start_time,
941+
iteration,
942+
usage.total,
943+
total_tokens,
944+
max_token_budget,
945+
),
934946
);
935947
}
936948

@@ -1298,27 +1310,58 @@ Do not invent tool names and do not place tool invocations in assistant content.
12981310
break;
12991311
}
13001312

1301-
// Safety limits -- Max Iterations Before Edit Check
1302-
if iteration == max_iterations_before_edit && !(made_edit || made_build_check) {
1313+
// Safety limits -- Max Token Budget
1314+
if total_tokens >= max_token_budget {
1315+
warn!(
1316+
"⚠️ Evolution reached token budget ({}/{}) - aborting",
1317+
total_tokens, max_token_budget
1318+
);
1319+
evolution.state = EvolutionState::Failed;
1320+
let stop_reason = format!(
1321+
"Token budget exhausted ({} of {} tokens)",
1322+
total_tokens, max_token_budget
1323+
);
1324+
emit_evolve_event(
1325+
app,
1326+
EvolveEvent::error(start_time, Some(iteration), &stop_reason, &stop_reason),
1327+
);
1328+
// Track failure
1329+
if let Err(e) = statistics::record_evolution_failure(app, iteration) {
1330+
warn!("Failed to record evolution failure stats: {}", e);
1331+
}
1332+
return Err(EvolutionRunError::from_state(
1333+
format!(
1334+
"Evolution stopped because the token budget was exhausted ({} of {} tokens)",
1335+
total_tokens, max_token_budget
1336+
),
1337+
&evolution,
1338+
iteration,
1339+
build_attempts,
1340+
total_tokens,
1341+
)
1342+
.into());
1343+
}
1344+
1345+
// Safety limits -- Token Budget Before Edit Check
1346+
if total_tokens >= max_tokens_before_edit && !made_edit_or_build_check {
13031347
warn!(
1304-
"⚠️ No edit or build_check by iteration {} - agent not making progress",
1305-
max_iterations_before_edit
1348+
"⚠️ No edit or build_check after {} tokens - agent not making progress",
1349+
total_tokens
13061350
);
13071351
evolution.state = EvolutionState::Failed;
13081352
let message = format!(
1309-
"I've analyzed your configuration for {} iterations but haven't started making concrete changes yet. \
1353+
"I've analyzed your configuration for {} tokens but haven't started making concrete changes yet. \
13101354
This suggests I'm having difficulty understanding what modifications you'd like. \
13111355
Could you provide more specific guidance on what aspects of your configuration need adjustment?",
1312-
max_iterations_before_edit
1356+
total_tokens
1357+
);
1358+
let stop_reason = format!(
1359+
"No concrete progress after {} of {} token budget",
1360+
total_tokens, max_token_budget
13131361
);
13141362
emit_evolve_event(
13151363
app,
1316-
EvolveEvent::error(
1317-
start_time,
1318-
Some(iteration),
1319-
&format!("Maximum iterations exceeded ({})", max_iterations),
1320-
&format!("Maximum iterations exceeded ({})", max_iterations),
1321-
),
1364+
EvolveEvent::error(start_time, Some(iteration), &stop_reason, &stop_reason),
13221365
);
13231366
// Track failure
13241367
if let Err(e) = statistics::record_evolution_failure(app, iteration) {
@@ -1334,28 +1377,27 @@ Could you provide more specific guidance on what aspects of your configuration n
13341377
.into());
13351378
}
13361379

1337-
// Safety limits -- Max Iterations
1338-
if iteration >= max_iterations {
1380+
// Safety limits -- Unmetered Provider Fallback
1381+
if !token_usage_observed && iteration >= legacy_max_iterations {
13391382
warn!(
1340-
"⚠️ Evolution exceeded maximum iterations ({}) - aborting",
1341-
max_iterations
1383+
"⚠️ Provider has not reported token usage after {} calls - aborting",
1384+
legacy_max_iterations
13421385
);
13431386
evolution.state = EvolutionState::Failed;
1387+
let stop_reason = format!(
1388+
"Provider did not report token usage; stopped after {} unmetered AI calls",
1389+
legacy_max_iterations
1390+
);
13441391
emit_evolve_event(
13451392
app,
1346-
EvolveEvent::error(
1347-
start_time,
1348-
Some(iteration),
1349-
&format!("Maximum iterations exceeded ({})", max_iterations),
1350-
&format!("Maximum iterations exceeded ({})", max_iterations),
1351-
),
1393+
EvolveEvent::error(start_time, Some(iteration), &stop_reason, &stop_reason),
13521394
);
13531395
// Track failure
13541396
if let Err(e) = statistics::record_evolution_failure(app, iteration) {
13551397
warn!("Failed to record evolution failure stats: {}", e);
13561398
}
13571399
return Err(EvolutionRunError::from_state(
1358-
format!("Evolution exceeded maximum iterations ({})", max_iterations),
1400+
stop_reason,
13591401
&evolution,
13601402
iteration,
13611403
build_attempts,

apps/native/src-tauri/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ fn run_cli_mode(context: tauri::Context<tauri::Wry>) -> i32 {
303303
config,
304304
max_iterations,
305305
max_output_tokens,
306+
max_token_budget,
306307
evolve_provider,
307308
evolve_model,
308309
summary_provider,
@@ -359,6 +360,7 @@ fn run_cli_mode(context: tauri::Context<tauri::Wry>) -> i32 {
359360
config,
360361
max_iterations,
361362
max_output_tokens,
363+
max_token_budget,
362364
evolve_provider,
363365
evolve_model,
364366
summary_provider,

apps/native/src-tauri/src/shared_types/prefs.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ pub struct UiPrefs {
3232
pub evolve_provider: Option<String>,
3333
/// Model used for AI evolution.
3434
pub evolve_model: Option<String>,
35-
/// Maximum agent iterations per evolution.
35+
/// Legacy maximum agent iterations per evolution.
3636
pub max_iterations: Option<usize>,
37+
/// Maximum provider-reported tokens per evolution.
38+
pub max_token_budget: Option<u32>,
3739
/// Maximum build attempts per evolution.
3840
pub max_build_attempts: Option<usize>,
3941
/// Maximum output tokens requested per evolution model call.
@@ -77,8 +79,10 @@ pub struct UiPrefsUpdate {
7779
pub summary_provider: Option<String>,
7880
/// Summary model update.
7981
pub summary_model: Option<String>,
80-
/// Maximum iteration count update.
82+
/// Legacy maximum iteration count update.
8183
pub max_iterations: Option<usize>,
84+
/// Maximum token budget update.
85+
pub max_token_budget: Option<u32>,
8286
/// Maximum build-attempt count update.
8387
pub max_build_attempts: Option<usize>,
8488
/// Maximum output token count update.

apps/native/src-tauri/src/storage/store.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ pub const UPDATE_CHANNEL_KEY: &str = "updateChannel";
4242

4343
pub const DEFAULT_MAX_ITERATIONS: usize = 25;
4444
pub const DEFAULT_MAX_OUTPUT_TOKENS: usize = 32_768;
45+
pub const DEFAULT_MAX_TOKEN_BUDGET: u32 = 50_000;
4546
const KEYCHAIN_SERVICE: &str = "com.darkmatter.nixmac";
4647

4748
fn e2e_mock_system_enabled() -> bool {
@@ -542,6 +543,18 @@ pub fn set_max_iterations<R: Runtime>(app: &AppHandle<R>, max: usize) -> Result<
542543
Ok(())
543544
}
544545

546+
/// Gets the maximum token budget for evolution (default: 50,000).
547+
pub fn get_max_token_budget<R: Runtime>(app: &AppHandle<R>) -> Result<u32> {
548+
Ok(get_json_pref(app, "maxTokenBudget")?.unwrap_or(DEFAULT_MAX_TOKEN_BUDGET))
549+
}
550+
551+
pub fn set_max_token_budget<R: Runtime>(app: &AppHandle<R>, max: u32) -> Result<()> {
552+
let store = get_store(app)?;
553+
store.set("maxTokenBudget", serde_json::json!(max));
554+
store.save()?;
555+
Ok(())
556+
}
557+
545558
/// Gets the maximum build attempts for evolution (default: 5). Repo-scoped.
546559
pub fn get_max_build_attempts<R: Runtime>(app: &AppHandle<R>) -> Result<usize> {
547560
if let Some(limits) =

0 commit comments

Comments
 (0)