1mod agent_cli;
14mod api;
15mod color;
16mod config;
17mod git;
18mod http;
19mod interrupt;
20mod model_timeout;
21mod progress;
22mod report;
23mod spec;
24mod sudo;
25mod tools;
26mod usage;
27mod validate;
28
29use std::collections::BTreeSet;
30use std::ffi::OsString;
31use std::io::Write;
32use std::os::unix::process::CommandExt;
33use std::path::{Path, PathBuf};
34use std::process::Command;
35use std::sync::{
36 atomic::{AtomicBool, Ordering},
37 Arc,
38};
39use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
40
41use anyhow::{Context, Result};
42use clap::Parser;
43
44use progress::ProgressSpinner;
45use report::{Report, RoundRecord};
46
47const SKILL_MD: &str = include_str!("../resources/SKILL.md");
50const OPTIMIZER_MD: &str = include_str!("../resources/OPTIMIZER.md");
51const KNOB_MD: &str = include_str!("../resources/KNOB.md");
52
53#[derive(Debug)]
54struct OptimizeOutcome {
55 report: Report,
56 interrupted: bool,
57}
58
59pub(crate) fn cargo_program() -> OsString {
60 std::env::var_os("CARGO")
61 .filter(|v| !v.is_empty())
62 .unwrap_or_else(|| OsString::from("cargo"))
63}
64
65#[derive(Parser, Debug)]
66#[command(
67 name = "scx-forge-agent",
68 version,
69 about = "LLM-driven sched_ext scheduler optimizer"
70)]
71struct OptimizeArgs {
72 #[arg(long)]
74 source: Option<PathBuf>,
75
76 #[arg(long)]
79 crate_dir: Option<String>,
80
81 #[arg(long, visible_alias = "spec-toml", value_name = "SPEC_TOML")]
85 spec: Option<PathBuf>,
86
87 #[arg(long, value_name = "PATH")]
89 save: Option<PathBuf>,
90
91 #[arg(long, value_name = "PATH")]
93 resume: Option<PathBuf>,
94
95 #[arg(long)]
98 dry_run: bool,
99
100 #[arg(long)]
102 keep_running: bool,
103
104 #[arg(long)]
106 json: bool,
107
108 #[arg(long)]
111 verbose: bool,
112
113 #[arg(long)]
115 no_color: bool,
116}
117
118fn resolve_repo_root(source: &Option<PathBuf>) -> Result<PathBuf> {
119 if let Some(s) = source {
120 return Ok(s.clone());
121 }
122 let out = Command::new("git")
123 .args(["rev-parse", "--show-toplevel"])
124 .output()
125 .context("git rev-parse --show-toplevel")?;
126 if !out.status.success() {
127 anyhow::bail!("not inside a git repo; pass --source");
128 }
129 Ok(PathBuf::from(
130 String::from_utf8_lossy(&out.stdout).trim().to_string(),
131 ))
132}
133
134fn cargo_build(
136 source: &Path,
137 package: &str,
138 profile: &str,
139 interrupted: &AtomicBool,
140) -> std::result::Result<(), String> {
141 let cargo = cargo_program();
142 let mut command = Command::new(&cargo);
143 command
144 .args(["build", "--profile", profile, "-p", package])
145 .current_dir(source)
146 .stdout(std::process::Stdio::piped())
147 .stderr(std::process::Stdio::piped());
148 unsafe {
149 command.pre_exec(|| {
150 libc::setsid();
151 Ok(())
152 });
153 }
154 let mut child = command
155 .spawn()
156 .map_err(|e| format!("spawn {} build: {e}", cargo.to_string_lossy()))?;
157 let pid = child.id() as i32;
158 loop {
159 if interrupt::requested(interrupted) {
160 unsafe {
161 libc::kill(-pid, libc::SIGINT);
162 }
163 let deadline = Instant::now() + Duration::from_secs(2);
164 loop {
165 match child.try_wait() {
166 Ok(Some(_)) | Err(_) => break,
167 Ok(None) if Instant::now() >= deadline => {
168 unsafe {
169 libc::kill(-pid, libc::SIGKILL);
170 }
171 break;
172 }
173 Ok(None) => std::thread::sleep(Duration::from_millis(50)),
174 }
175 }
176 let out = child
177 .wait_with_output()
178 .map_err(|e| format!("collect {} build output: {e}", cargo.to_string_lossy()))?;
179 let mut s = String::from("cargo build interrupted by Ctrl-C\n");
180 s.push_str(&String::from_utf8_lossy(&out.stderr));
181 s.push_str(&String::from_utf8_lossy(&out.stdout));
182 return Err(s);
183 }
184 match child.try_wait() {
185 Ok(Some(_)) => break,
186 Ok(None) => std::thread::sleep(Duration::from_millis(100)),
187 Err(e) => return Err(format!("poll {} build: {e}", cargo.to_string_lossy())),
188 }
189 }
190 let out = child
191 .wait_with_output()
192 .map_err(|e| format!("collect {} build output: {e}", cargo.to_string_lossy()))?;
193 if out.status.success() {
194 Ok(())
195 } else {
196 let mut s = String::from_utf8_lossy(&out.stderr).to_string();
197 s.push_str(&String::from_utf8_lossy(&out.stdout));
198 Err(s)
199 }
200}
201
202fn cargo_build_with_progress(
203 source: &Path,
204 package: &str,
205 profile: &str,
206 show_progress: bool,
207 color: color::Style,
208 interrupted: &AtomicBool,
209) -> std::result::Result<(), String> {
210 let _spinner = show_progress
211 .then(|| ProgressSpinner::stdout(format!("re-building scheduler {package}..."), color));
212 cargo_build(source, package, profile, interrupted)
213}
214
215fn home_dir() -> Option<PathBuf> {
216 std::env::var_os("HOME")
217 .filter(|home| !home.is_empty())
218 .map(PathBuf::from)
219}
220
221fn expand_glob_path(path: &Path) -> Result<PathBuf> {
222 let pattern = path.to_string_lossy();
223 if glob::Pattern::escape(&pattern) == pattern {
224 return Ok(path.to_path_buf());
225 }
226
227 let mut matches = glob::glob(&pattern)
228 .with_context(|| format!("parse [system].sudo_passwd_file pattern: {pattern}"))?
229 .collect::<std::result::Result<Vec<_>, _>>()
230 .with_context(|| format!("expand [system].sudo_passwd_file pattern: {pattern}"))?;
231 matches.sort();
232
233 match matches.len() {
234 0 => anyhow::bail!(
235 "[system].sudo_passwd_file pattern matched no files: {}",
236 path.display()
237 ),
238 1 => Ok(matches.remove(0)),
239 n => anyhow::bail!(
240 "[system].sudo_passwd_file pattern is ambiguous ({} matches): {}",
241 n,
242 path.display()
243 ),
244 }
245}
246
247fn expand_sudo_password_file(spec_path: &Path, pw: &Path) -> Result<PathBuf> {
248 let raw = pw.to_string_lossy();
249 let expanded = if raw == "~" {
250 home_dir().context("expand ~ in [system].sudo_passwd_file: HOME is not set")?
251 } else if let Some(rest) = raw.strip_prefix("~/") {
252 home_dir()
253 .context("expand ~ in [system].sudo_passwd_file: HOME is not set")?
254 .join(rest)
255 } else {
256 let path = PathBuf::from(raw.as_ref());
257 if path.is_absolute() {
258 path
259 } else {
260 spec_path
261 .parent()
262 .unwrap_or_else(|| Path::new("."))
263 .join(path)
264 }
265 };
266 expand_glob_path(&expanded)
267}
268
269fn spec_sudo_password_file(spec_path: &Path, spec: &spec::Spec) -> Result<Option<PathBuf>> {
270 let Some(pw) = spec.system.sudo_passwd_file.as_ref() else {
271 return Ok(None);
272 };
273 if pw.as_os_str().is_empty() {
274 return Ok(None);
275 }
276 Ok(Some(expand_sudo_password_file(spec_path, pw)?))
277}
278
279fn configure_sudo_from_spec(spec_path: &Path, spec: &spec::Spec) -> Result<()> {
280 let Some(pw) = spec_sudo_password_file(spec_path, spec)? else {
281 return Ok(());
282 };
283 if !pw.is_file() {
284 anyhow::bail!("[system].sudo_passwd_file not found: {}", pw.display());
285 }
286 std::env::set_var("SCX_SUDO_PASSWORD_FILE", &pw);
287 Ok(())
288}
289
290fn agent_log(color: color::Style, msg: impl AsRef<str>) {
291 eprintln!("{} {}", color.dim("[scx-forge-agent]"), msg.as_ref());
292}
293
294fn agent_info(color: color::Style, msg: impl AsRef<str>) {
295 agent_log(color, color.cyan(msg));
296}
297
298fn agent_warn(color: color::Style, msg: impl AsRef<str>) {
299 agent_log(color, color.yellow(msg));
300}
301
302fn agent_error(color: color::Style, msg: impl AsRef<str>) {
303 agent_log(color, color.red(msg));
304}
305
306fn agent_success(color: color::Style, msg: impl AsRef<str>) {
307 agent_log(color, color.green(msg));
308}
309
310fn agent_dim(color: color::Style, msg: impl AsRef<str>) {
311 agent_log(color, color.dim(msg));
312}
313
314fn model_display_name(model: &config::ModelConfig) -> String {
315 if model.model_id.trim().is_empty() {
316 format!("{} default", model.backend.as_str())
317 } else {
318 model.model_id.clone()
319 }
320}
321
322fn model_backend_label(model: &config::ModelConfig) -> String {
326 if model.backend == config::Backend::OpenAi && !model.base_url.is_empty() {
327 format!("{} @ {}", model.backend.as_str(), model.base_url)
328 } else {
329 model.backend.as_str().to_string()
330 }
331}
332
333struct ActiveModelPrinter {
334 enabled: bool,
335 color: color::Style,
336 active: Option<(String, config::Backend, String)>,
337}
338
339impl ActiveModelPrinter {
340 fn new(enabled: bool, color: color::Style) -> Self {
341 Self {
342 enabled,
343 color,
344 active: None,
345 }
346 }
347
348 fn use_model(&mut self, role: &str, model: &config::ModelConfig) {
349 let model_name = model_display_name(model);
350 let next = (role.to_string(), model.backend, model_name.clone());
351 if !self.enabled || self.active.as_ref() == Some(&next) {
352 self.active = Some(next);
353 return;
354 }
355
356 println!(
357 "{}",
358 self.color.dim(format!(
359 "[scx-forge-agent] using {role} model: {model_name} ({})",
360 model_backend_label(model)
361 ))
362 );
363 self.active = Some(next);
364 }
365}
366
367#[allow(clippy::too_many_arguments)]
368async fn run_coding_turn(
369 client: &reqwest::Client,
370 models: &config::ModelRoles,
371 coding_system: &str,
372 user: &str,
373 scheduler_cwd: &Path,
374 edit_tl: &api::ToolLoopConfig,
375 verbose: bool,
376 stream_stdout: bool,
377 stderr_color: color::Style,
378 active_model: &mut ActiveModelPrinter,
379 total_usage: &mut usage::Usage,
380 turn_timeout: Duration,
381 interrupted: Arc<AtomicBool>,
382) -> Result<std::result::Result<String, String>> {
383 active_model.use_model("coding", &models.coding);
384 if models.coding.backend.is_subprocess() {
385 let content = agent_cli::run(
386 &models.coding,
387 agent_cli::Mode::Edit,
388 stream_stdout,
389 coding_system,
390 user,
391 scheduler_cwd,
392 verbose,
393 stderr_color,
394 total_usage,
395 turn_timeout,
396 interrupted,
397 )
398 .await?;
399 return Ok(Ok(content));
400 }
401
402 match api::chat(
403 client,
404 &models.coding,
405 coding_system,
406 user,
407 Some(edit_tl),
408 verbose,
409 stderr_color,
410 stream_stdout,
411 total_usage,
412 turn_timeout,
413 interrupted,
414 )
415 .await
416 {
417 Ok(content) => Ok(Ok(content)),
418 Err(e) if api::is_api_error(&e) => Ok(Err(api::error_summary(&e))),
419 Err(e) => Err(e),
420 }
421}
422
423fn validation_failure_feedback(verdict: &validate::Verdict) -> String {
424 let mut s = format!(
425 "Validation failed at stage `{}`. Fix the current candidate before it is reverted.\n",
426 verdict.stage
427 );
428 if verdict.stage == "runtime" {
429 s.push_str(
430 "The scheduler was not still enabled after the workload, so the candidate likely triggered a sched_ext runtime failure, stall, deadlock, BPF error, or abort and may have measured the default scheduler.\n",
431 );
432 } else if verdict.stage == "metric" {
433 s.push_str(
434 "The workload did not produce a parseable numeric metric. Treat this as a runtime failure of the candidate: the scheduler may have broken, stalled, or disrupted the workload before it could emit a valid score.\n",
435 );
436 }
437 if !verdict.errors.is_empty() {
438 s.push_str("\nErrors:\n");
439 for err in &verdict.errors {
440 s.push_str("- ");
441 s.push_str(err);
442 s.push('\n');
443 }
444 }
445 if let Ok(raw) = serde_json::to_string_pretty(&verdict.raw) {
446 s.push_str("\nVerdict JSON:\n");
447 s.push_str(&raw);
448 s.push('\n');
449 }
450 s
451}
452
453fn should_try_runtime_fix(verdict: &validate::Verdict) -> bool {
454 matches!(verdict.stage.as_str(), "runtime" | "metric")
455}
456
457fn read_attempt_memory(path: &Path) -> Result<Option<String>> {
458 if !path.exists() {
459 return Ok(None);
460 }
461 let text = std::fs::read_to_string(path)
462 .with_context(|| format!("read state file {}", path.display()))?;
463 let text = text.trim();
464 if text.is_empty() {
465 Ok(None)
466 } else {
467 Ok(Some(text.to_string()))
468 }
469}
470
471fn attempt_memory_for_prompt(memory: Option<&str>) -> Option<&str> {
472 memory
473 .map(str::trim)
474 .filter(|s| !s.is_empty())
475 .map(|s| suffix_at_char_boundary(s, 12_000))
476}
477
478fn attempt_summary_line(r: &RoundRecord) -> String {
479 format!(
480 "| {} | {} | {} | {} | {} | {} | {} |\n",
481 r.round,
482 r.outcome.replace('|', "\\|"),
483 r.value
484 .map(|v| format!("{v:.3}"))
485 .unwrap_or_else(|| "-".into()),
486 r.improvement
487 .map(|v| format!("{v:+.3}"))
488 .unwrap_or_else(|| "-".into()),
489 r.policy_area.replace('|', "\\|"),
490 r.direction.replace('|', "\\|"),
491 r.summary.replace('|', "\\|").replace('\n', " "),
492 )
493}
494
495fn run_attempt_summary(
496 rep: &Report,
497 package: &str,
498 crate_dir: &str,
499 spec_path: &Path,
500 baseline_sha: &str,
501) -> String {
502 let ts = SystemTime::now()
503 .duration_since(UNIX_EPOCH)
504 .map(|d| d.as_secs())
505 .unwrap_or_default();
506 let mut s = String::new();
507 s.push_str(&format!("\n## Run {ts}\n\n"));
508 s.push_str(&format!("- Package: `{package}`\n"));
509 s.push_str(&format!("- Crate dir: `{crate_dir}`\n"));
510 s.push_str(&format!("- Spec: `{}`\n", spec_path.display()));
511 s.push_str(&format!("- Baseline: `{baseline_sha}`\n"));
512 s.push_str(&format!(
513 "- Objective: {} `{}`\n",
514 rep.goal, rep.metric_name
515 ));
516 s.push_str(&format!(
517 "- Start: {}\n",
518 rep.start_value
519 .map(|v| format!("{v:.3}"))
520 .unwrap_or_else(|| "-".into())
521 ));
522 s.push_str(&format!(
523 "- Best: {}{}\n\n",
524 rep.best_value
525 .map(|v| format!("{v:.3}"))
526 .unwrap_or_else(|| "-".into()),
527 rep.best_round
528 .map(|r| format!(" (round {r})"))
529 .unwrap_or_default()
530 ));
531 s.push_str("| round | outcome | value | improvement | area | direction | change |\n");
532 s.push_str("|---:|---|---:|---:|---|---|---|\n");
533 for r in &rep.rounds {
534 s.push_str(&attempt_summary_line(r));
535 }
536 s
537}
538
539fn append_attempt_summary(
540 path: &Path,
541 summary: &str,
542 color: color::Style,
543 json: bool,
544) -> Result<()> {
545 if let Some(parent) = path.parent() {
546 std::fs::create_dir_all(parent)
547 .with_context(|| format!("create state file dir {}", parent.display()))?;
548 }
549 let existed = path.exists();
550 let mut file = std::fs::OpenOptions::new()
551 .create(true)
552 .append(true)
553 .open(path)
554 .with_context(|| format!("open state file {}", path.display()))?;
555 if !existed {
556 writeln!(
557 file,
558 "# scx_forge_agent state\n\nThis file is written by scx-forge-agent when `--save` is used and can be loaded into future runs with `--resume` as factual context for future experiments."
559 )
560 .with_context(|| format!("initialize state file {}", path.display()))?;
561 }
562 file.write_all(summary.as_bytes())
563 .with_context(|| format!("write state file {}", path.display()))?;
564 if !json {
565 println!(
566 "{}",
567 color.dim(format!(
568 "[scx-forge-agent] saved attempt summary: {}",
569 path.display()
570 ))
571 );
572 }
573 Ok(())
574}
575
576fn install_ctrl_c_handler(
577 interrupted: Arc<AtomicBool>,
578 color: color::Style,
579 json: bool,
580 source: PathBuf,
581 crate_dir: String,
582) {
583 tokio::spawn(async move {
584 if tokio::signal::ctrl_c().await.is_err() {
585 return;
586 }
587
588 interrupted.store(true, Ordering::SeqCst);
589 if !json {
590 eprintln!(
591 "{}",
592 color.yellow(
593 "[scx-forge-agent] Ctrl-C received; interrupting the current operation and printing the partial report. Press Ctrl-C again to exit immediately."
594 )
595 );
596 }
597
598 if tokio::signal::ctrl_c().await.is_ok() {
599 if !json {
600 eprintln!(
601 "{}",
602 color.red("[scx-forge-agent] second Ctrl-C received; exiting immediately.")
603 );
604 }
605 git::discard(&source, &crate_dir).ok();
611 git::unstage(&source, &crate_dir).ok();
612 std::process::exit(130);
613 }
614 });
615}
616
617fn interrupted_requested(interrupted: &AtomicBool) -> bool {
618 interrupt::requested(interrupted)
619}
620
621fn build_keep_running_scheduler(
622 source: &Path,
623 package: &str,
624 profile: &str,
625 stdout_color: color::Style,
626 stderr_color: color::Style,
627 json: bool,
628 interrupted: &AtomicBool,
629) -> Result<()> {
630 agent_info(
631 stderr_color,
632 "preparing final scheduler for --keep-running foreground launch...",
633 );
634 cargo_build_with_progress(source, package, profile, !json, stdout_color, interrupted).map_err(
635 |err| {
636 anyhow::anyhow!(
637 "final scheduler build failed for --keep-running:\n{}",
638 suffix_at_char_boundary(&err, 4000)
639 )
640 },
641 )?;
642
643 Ok(())
644}
645
646fn start_keep_running_scheduler(
647 source: &Path,
648 package: &str,
649 profile: &str,
650 sudo: &sudo::Sudo,
651 stderr_color: color::Style,
652) -> Result<()> {
653 agent_info(
654 stderr_color,
655 "starting final scheduler in foreground; press Ctrl-C to stop it",
656 );
657 std::io::stdout()
658 .flush()
659 .context("flush stdout before scheduler launch")?;
660 std::io::stderr()
661 .flush()
662 .context("flush stderr before scheduler launch")?;
663 validate::exec_scheduler_foreground(source, package, profile, sudo)
664}
665
666fn print_final_report(
667 rep: &Report,
668 interrupted: bool,
669 json: bool,
670 stdout_color: color::Style,
671 crate_dir: &str,
672) -> Result<()> {
673 if json {
674 let mut out = rep.to_json();
675 if let Some(obj) = out.as_object_mut() {
676 if interrupted {
677 obj.insert("interrupted".to_string(), serde_json::Value::Bool(true));
678 }
679 }
680 println!("{}", serde_json::to_string_pretty(&out)?);
681 return Ok(());
682 }
683
684 if interrupted {
685 println!(
686 "{}\n",
687 stdout_color.yellow("Interrupted by Ctrl-C; partial report follows.")
688 );
689 }
690
691 println!("{}", rep.render_markdown());
692 if rep.best_round.is_some() {
693 println!(
694 "\n{}",
695 stdout_color.green(format!(
696 "Winning variant applied in place; review with `git diff -- {}`.",
697 crate_dir
698 ))
699 );
700 } else {
701 println!(
702 "\n{}",
703 stdout_color.yellow("No improvement found; crate left unchanged.")
704 );
705 }
706 println!("\n{}", stdout_color.dim(rep.usage.footer_line()));
708
709 Ok(())
710}
711
712fn base_system(package: &str, crate_dir: &str, scheduler_refs: bool) -> String {
713 let refs_sentence = if scheduler_refs {
714 " Read-only tools list_schedulers / grep_schedulers / read_scheduler_file \
715 inspect other scheduler crates under `scheds/rust` for reference."
716 } else {
717 ""
718 };
719 format!(
720 "You are optimizing the sched_ext scheduler `{package}` (the crate at \
721 `{crate_dir}`) to improve a benchmark metric. A deterministic harness \
722 builds, runs, and measures every change and keeps it only if the metric \
723 improves.\n\n\
724 File tools (read_file / list_dir / grep / edit_file) are rooted at the \
725 crate, so paths are relative to the crate root: `src/bpf/main.bpf.c` for \
726 the BPF policy, `src/main.rs` for the control plane.{refs_sentence} All \
727 edits stay inside this crate, and the code must build and pass the BPF \
728 verifier."
729 )
730}
731
732fn planner_system_prompt(
733 package: &str,
734 crate_dir: &str,
735 knob_help: Option<&str>,
736 scheduler_refs: bool,
737) -> String {
738 let mut s = format!(
739 "{}\n\n{OPTIMIZER_MD}\n\n\
740 You are the PLANNER: inspect the crate's code and return one concrete \
741 experiment plan. Do not edit files.",
742 base_system(package, crate_dir, scheduler_refs)
743 );
744 if scheduler_refs {
745 s.push_str(
746 "\n\nThe cross-scheduler reference tools are enabled: you may also \
747 explore other scheduler crates under `scheds/rust` \
748 (list_schedulers / grep_schedulers / read_scheduler_file) and port a \
749 self-consistent mechanism the target lacks, adapting it to this \
750 crate's callbacks, maps, and task lifecycle. Cite which scheduler you \
751 borrowed the idea from.",
752 );
753 }
754 if let Some(help) = knob_help.map(str::trim).filter(|h| !h.is_empty()) {
755 s.push_str(
756 "\n\n## Already-implemented options (do NOT re-propose these)\n\
757 The scheduler already exposes the command-line options below, and an \
758 earlier sweep has tuned them to their best values. They are existing \
759 configuration, not new logic: do not propose changing any of them as \
760 your experiment - it will not improve the metric further. Infer from \
761 this list what has already been tried, and propose a mechanism the \
762 scheduler does not implement yet.\n\n```\n",
763 );
764 s.push_str(help);
765 s.push_str("\n```\n");
766 }
767 s
768}
769
770fn scheduler_help_text(repo_root: &Path, package: &str, profile: &str) -> Option<String> {
775 let bin = validate::binary_path(repo_root, package, profile);
776 let out = std::process::Command::new(&bin)
777 .arg("--help")
778 .output()
779 .ok()?;
780 let text = String::from_utf8_lossy(&out.stdout);
781 let text = text.trim();
782 if text.is_empty() {
783 None
784 } else {
785 Some(text.to_string())
786 }
787}
788
789const KNOB_PHASE_DONE_SENTINEL: &str = "KNOB_PHASE_COMPLETE";
792
793#[derive(Debug, Clone, Copy, PartialEq, Eq)]
797enum Phase {
798 Knob,
799 Mechanism,
800}
801
802fn knob_phase_complete(plan: &str) -> bool {
804 plan.lines().any(|l| l.trim() == KNOB_PHASE_DONE_SENTINEL)
805}
806
807fn knob_inventory(repo_root: &Path, package: &str, profile: &str) -> String {
813 let mut s = String::new();
814 if let Some(help) = scheduler_help_text(repo_root, package, profile) {
815 s.push_str("## Command-line options (from --help)\n```\n");
816 s.push_str(&help);
817 s.push_str("\n```\n");
818 }
819 s
820}
821
822fn knob_system_prompt(package: &str, crate_dir: &str, inventory: &str) -> String {
826 let mut s = format!(
827 "{}\n\n{}",
828 base_system(package, crate_dir, false),
829 KNOB_MD.trim()
830 );
831 s.push_str(&format!(
832 "\n\n\
833 When no untested option value looks promising (the knob space is \
834 effectively exhausted), reply with exactly `{KNOB_PHASE_DONE_SENTINEL}` \
835 on its own line and nothing else."
836 ));
837 s.push_str(
838 "\n\n\
839 Do not edit files; return the plan only.",
840 );
841 let inv = inventory.trim();
842 if !inv.is_empty() {
843 s.push_str("\n\n## Available configuration options\n");
844 s.push_str(inv);
845 }
846 s
847}
848
849fn coding_system_prompt(package: &str, crate_dir: &str, scheduler_refs: bool) -> String {
850 format!(
851 "{}\n\n\
852 You are the CODER: implement the experiment described in the user \
853 message with the `edit_file` tool, keeping the edit focused and \
854 preserving sched_ext lifecycle semantics. Follow the BPF safety rules in \
855 the reference below; in particular split chained lookups like \
856 `lookup_x(...)->field` into a pointer, a NULL check, then the deref.\n\n\
857 Every change must alter scheduling behavior in this same edit. If you \
858 add a struct field, map, global, or local, make sure it is BOTH read \
859 and written by the code within the same change: read-only or write-only \
860 state produces no change to scheduler behavior, wastes the round, and is \
861 rejected. The value you write must flow into a decision that affects \
862 placement, ordering, dispatch, vtime/deadline, slice, or preemption. \
863 When done, reply with a one-line summary of the change.\n\n\
864 --- BEGIN SKILL.md (sched_ext domain reference) ---\n\n{SKILL_MD}\n\n\
865 --- END SKILL.md ---\n",
866 base_system(package, crate_dir, scheduler_refs)
867 )
868}
869
870fn normalize_diff(diff: &str) -> String {
873 diff.lines()
874 .filter(|l| !l.starts_with("index "))
875 .collect::<Vec<_>>()
876 .join("\n")
877 .trim()
878 .to_string()
879}
880
881fn prefix_at_char_boundary(s: &str, max_bytes: usize) -> &str {
882 if s.len() <= max_bytes {
883 return s;
884 }
885 let end = s
886 .char_indices()
887 .map(|(idx, _)| idx)
888 .take_while(|idx| *idx <= max_bytes)
889 .last()
890 .unwrap_or(0);
891 &s[..end]
892}
893
894fn suffix_at_char_boundary(s: &str, max_bytes: usize) -> &str {
895 if s.len() <= max_bytes {
896 return s;
897 }
898 let target = s.len() - max_bytes;
899 let start = s
900 .char_indices()
901 .map(|(idx, _)| idx)
902 .find(|idx| *idx >= target)
903 .unwrap_or(s.len());
904 &s[start..]
905}
906
907fn normalize_attempt_summary(summary: &str) -> Option<String> {
908 let normalized = summary
909 .chars()
910 .map(|ch| {
911 if ch.is_ascii_alphanumeric() {
912 ch.to_ascii_lowercase()
913 } else {
914 ' '
915 }
916 })
917 .collect::<String>()
918 .split_whitespace()
919 .collect::<Vec<_>>()
920 .join(" ");
921
922 if normalized.is_empty() || normalized == "no summary" {
923 None
924 } else {
925 Some(normalized)
926 }
927}
928
929fn planner_plan_rejection_reason(plan: &str) -> Option<&'static str> {
930 let trimmed = plan.trim();
931 if trimmed.is_empty() {
932 return Some("empty planner response");
933 }
934
935 let lower = trimmed.to_ascii_lowercase();
936 if lower.starts_with("error:")
937 || lower.starts_with("failed:")
938 || lower.contains("error: unknown tool")
939 || lower.contains("unknown tool:")
940 {
941 return Some("planner returned a tool error instead of a plan");
942 }
943
944 None
945}
946
947fn planner_recovery_note(reason: &str) -> String {
948 format!(
949 "The previous planner turn did not produce a usable plan ({reason}). \
950 Tool failures and unknown tools are recoverable. Do not stop planning, \
951 do not switch to coding, and do not call edit tools. Continue with the \
952 advertised read-only tools and the context already gathered, then return \
953 the concrete experiment plan now."
954 )
955}
956
957#[derive(Debug, Clone, PartialEq, Eq)]
958struct AttemptTags {
959 policy_area: String,
960 direction: String,
961}
962
963fn contains_any(text: &str, needles: &[&str]) -> bool {
964 needles.iter().any(|needle| text.contains(needle))
965}
966
967fn classify_policy_area(text: &str) -> &'static str {
968 if contains_any(
969 text,
970 &[
971 "select_cpu",
972 "pick_cpu",
973 "prev_cpu",
974 "idle cpu",
975 "idle_cpu",
976 "wakeup cpu",
977 "placement",
978 "affinity",
979 "migrate",
980 ],
981 ) {
982 "placement"
983 } else if contains_any(
984 text,
985 &[
986 "vtime",
987 "vruntime",
988 "deadline",
989 "lag",
990 "dsq_insert_vtime",
991 "virtual time",
992 ],
993 ) {
994 "vtime"
995 } else if contains_any(text, &["preempt", "kick", "resched", "yield", "yielding"]) {
996 "preemption"
997 } else if contains_any(text, &["slice", "quantum", "timeslice"]) {
998 "slice"
999 } else if contains_any(
1000 text,
1001 &[
1002 "dispatch",
1003 "consume",
1004 "move_to_local",
1005 "pull",
1006 "steal",
1007 "drain",
1008 ],
1009 ) {
1010 "dispatch"
1011 } else if contains_any(
1012 text,
1013 &[
1014 "topology", "domain", "llc", "numa", "node", "cpumask", "cluster",
1015 ],
1016 ) {
1017 "topology"
1018 } else if contains_any(
1019 text,
1020 &[
1021 "load balance",
1022 "load_balance",
1023 "queue depth",
1024 "nr_queued",
1025 "nr_running",
1026 "runnable",
1027 ],
1028 ) {
1029 "load_balance"
1030 } else if contains_any(
1031 text,
1032 &[
1033 "classify",
1034 "interactive",
1035 "latency",
1036 "batch",
1037 "weight",
1038 "per-task",
1039 "per task",
1040 "per-cpu",
1041 "per cpu",
1042 ],
1043 ) {
1044 "classification"
1045 } else {
1046 "unknown"
1047 }
1048}
1049
1050fn direction_stopword(word: &str) -> bool {
1051 matches!(
1052 word,
1053 "a" | "an"
1054 | "and"
1055 | "as"
1056 | "by"
1057 | "for"
1058 | "from"
1059 | "in"
1060 | "into"
1061 | "of"
1062 | "on"
1063 | "or"
1064 | "the"
1065 | "to"
1066 | "with"
1067 | "changed"
1068 | "change"
1069 | "modified"
1070 | "modify"
1071 | "added"
1072 | "add"
1073 | "updated"
1074 | "update"
1075 | "use"
1076 | "uses"
1077 | "using"
1078 | "should"
1079 | "metric"
1080 | "score"
1081 )
1082}
1083
1084fn classify_attempt(summary: &str, diff: &str) -> AttemptTags {
1085 let combined = format!("{summary}\n{diff}").to_ascii_lowercase();
1086 let area = classify_policy_area(&combined).to_string();
1087 let direction_src = if summary.trim().is_empty() {
1088 diff.lines().take(40).collect::<Vec<_>>().join(" ")
1089 } else {
1090 summary.to_string()
1091 };
1092 let mut words = normalize_attempt_summary(&direction_src)
1093 .unwrap_or_else(|| area.clone())
1094 .split_whitespace()
1095 .filter(|w| !direction_stopword(w))
1096 .take(8)
1097 .map(ToString::to_string)
1098 .collect::<Vec<_>>();
1099 if words.is_empty() {
1100 words.push(area.clone());
1101 }
1102 AttemptTags {
1103 policy_area: area,
1104 direction: words.join("_"),
1105 }
1106}
1107
1108fn normalized_improvement(goal: &str, delta: f64) -> f64 {
1109 if goal == "minimize" {
1110 -delta
1111 } else {
1112 delta
1113 }
1114}
1115
1116#[allow(clippy::too_many_arguments)]
1117fn round_record(
1118 round: u32,
1119 summary: String,
1120 outcome: &str,
1121 value: Option<f64>,
1122 delta: Option<f64>,
1123 kept: bool,
1124 goal: &str,
1125 diff: &str,
1126) -> RoundRecord {
1127 let tags = classify_attempt(&summary, diff);
1128 RoundRecord {
1129 round,
1130 summary,
1131 outcome: outcome.to_string(),
1132 value,
1133 delta,
1134 improvement: delta.map(|d| normalized_improvement(goal, d)),
1135 policy_area: tags.policy_area,
1136 direction: tags.direction,
1137 kept,
1138 }
1139}
1140
1141fn record_interrupted_round(
1142 rep: &mut Report,
1143 source: &Path,
1144 crate_dir: &str,
1145 round: u32,
1146 summary: String,
1147 goal: &str,
1148) {
1149 let attempt_diff = git::worktree_diff(source, crate_dir).unwrap_or_default();
1150 git::discard(source, crate_dir).ok();
1151 rep.rounds.push(round_record(
1152 round,
1153 summary,
1154 "interrupted",
1155 None,
1156 None,
1157 false,
1158 goal,
1159 &attempt_diff,
1160 ));
1161}
1162
1163fn record_model_timeout_round(
1164 rep: &mut Report,
1165 source: &Path,
1166 crate_dir: &str,
1167 round: u32,
1168 summary: String,
1169 goal: &str,
1170 made_edit: bool,
1171) {
1172 let attempt_diff = if made_edit {
1173 git::worktree_diff(source, crate_dir).unwrap_or_default()
1174 } else {
1175 String::new()
1176 };
1177 git::discard(source, crate_dir).ok();
1178 rep.rounds.push(round_record(
1179 round,
1180 summary,
1181 "model-timeout",
1182 None,
1183 None,
1184 false,
1185 goal,
1186 &attempt_diff,
1187 ));
1188}
1189
1190fn diff_file_path(line: &str) -> Option<String> {
1191 if !line.starts_with("diff --git ") {
1192 return None;
1193 }
1194 let path = line.split_whitespace().nth(3)?;
1195 Some(path.strip_prefix("b/").unwrap_or(path).to_string())
1196}
1197
1198fn bpf_const_volatile_initializer_name(diff_line: &str) -> Option<String> {
1199 if !(diff_line.starts_with('+') || diff_line.starts_with('-'))
1200 || diff_line.starts_with("+++")
1201 || diff_line.starts_with("---")
1202 {
1203 return None;
1204 }
1205 let code = diff_line.get(1..)?.trim();
1206 if !code.starts_with("const volatile ") || !code.ends_with(';') {
1207 return None;
1208 }
1209 let lhs = code.split_once('=')?.0.trim();
1210 let name = lhs.split_whitespace().last()?;
1211 Some(
1212 name.split_once('[')
1213 .map_or(name, |(base, _)| base)
1214 .to_string(),
1215 )
1216}
1217
1218fn bpf_const_volatile_initializer_only_vars(diff: &str) -> Option<Vec<String>> {
1219 let mut current_file = None;
1220 let mut vars = BTreeSet::new();
1221
1222 for line in diff.lines() {
1223 if let Some(path) = diff_file_path(line) {
1224 current_file = Some(path);
1225 continue;
1226 }
1227 if !(line.starts_with('+') || line.starts_with('-'))
1228 || line.starts_with("+++")
1229 || line.starts_with("---")
1230 {
1231 continue;
1232 }
1233 let file = current_file.as_deref()?;
1234 if !file.ends_with(".bpf.c") {
1235 return None;
1236 }
1237 let name = bpf_const_volatile_initializer_name(line)?;
1238 vars.insert(name);
1239 }
1240
1241 if vars.is_empty() {
1242 None
1243 } else {
1244 Some(vars.into_iter().collect())
1245 }
1246}
1247
1248fn ineffective_round_edit_reason(diff: &str) -> Option<String> {
1249 let vars = bpf_const_volatile_initializer_only_vars(diff)?;
1250 Some(format!(
1251 "only changed BPF const volatile initializer(s): {}. Rust rodata assignments usually overwrite these defaults before load.",
1252 vars.join(", ")
1253 ))
1254}
1255
1256fn ineffective_round_note(reason: &str) -> String {
1257 format!(
1258 "Your last edit was rejected before validation because it {reason} If tuning one of these knobs, update the Rust-side default / CLI plumbing / rodata_data assignment in src/main.rs in the same edit. Otherwise choose a runtime scheduling policy change such as task placement, queue ordering, DSQ selection/topology, dispatch pulling, deadline/vtime calculation, or preemption/kick behavior."
1259 )
1260}
1261
1262fn history_table(rounds: &[RoundRecord]) -> String {
1263 if rounds.is_empty() {
1264 return "(no rounds yet)".to_string();
1265 }
1266 let mut s =
1267 String::from("round | outcome | value | improvement | area | direction | kept | change\n");
1268 for r in rounds {
1269 s.push_str(&format!(
1270 "{} | {} | {} | {} | {} | {} | {} | {}\n",
1271 r.round,
1272 r.outcome,
1273 r.value
1274 .map(|v| format!("{v:.3}"))
1275 .unwrap_or_else(|| "-".into()),
1276 r.improvement
1277 .map(|v| format!("{v:+.3}"))
1278 .unwrap_or_else(|| "-".into()),
1279 r.policy_area,
1280 r.direction,
1281 r.kept,
1282 r.summary,
1283 ));
1284 }
1285 s
1286}
1287
1288#[allow(clippy::too_many_arguments)]
1289fn round_context_prompt(
1290 metric_name: &str,
1291 goal: &str,
1292 goal_description: Option<&str>,
1293 best_value: Option<f64>,
1294 history: &[RoundRecord],
1295 attempt_memory: Option<&str>,
1296 last_verdict_json: &str,
1297 crate_diff: &str,
1298 build_errors: Option<&str>,
1299 note: Option<&str>,
1300) -> String {
1301 let mut s = String::new();
1302 if let Some(d) = goal_description {
1303 s.push_str(&format!("Goal: {d}\n"));
1304 }
1305 s.push_str(&format!("Concretely, {goal} the metric `{metric_name}`.\n"));
1306 if let Some(b) = best_value {
1307 s.push_str(&format!("Best `{metric_name}` so far (accepted): {b:.3}\n"));
1308 }
1309 s.push_str(
1310 "Only changes that improve the metric this round are kept; non-improving \
1311 rounds are reverted. Make one self-contained change that affects \
1312 scheduling behavior immediately.\n",
1313 );
1314 if let Some(memory) = attempt_memory_for_prompt(attempt_memory) {
1315 s.push_str("\n## Previous run attempt memory\n");
1316 s.push_str(memory);
1317 s.push('\n');
1318 }
1319 s.push_str("\n## Optimization history\n");
1320 s.push_str(&history_table(history));
1321
1322 s.push_str("\n## Last benchmark result (JSON)\n");
1323 s.push_str(prefix_at_char_boundary(last_verdict_json, 4000));
1324 s.push('\n');
1325
1326 s.push_str("\n## Currently applied changes (diff vs baseline)\n");
1327 if crate_diff.trim().is_empty() {
1328 s.push_str("(none yet - the scheduler is at its baseline)\n");
1329 } else {
1330 s.push_str("```diff\n");
1331 s.push_str(prefix_at_char_boundary(crate_diff, 6000));
1332 s.push_str("\n```\n");
1333 }
1334
1335 if let Some(err) = build_errors {
1336 s.push_str("\n## The previous build or validation FAILED. Fix it with one edit.\n```\n");
1337 s.push_str(suffix_at_char_boundary(err, 4000));
1338 s.push_str("\n```\n");
1339 }
1340
1341 if let Some(n) = note {
1342 s.push_str(&format!("\n## Note\n{n}\n"));
1343 }
1344
1345 s
1346}
1347
1348#[allow(clippy::too_many_arguments)]
1349fn planner_prompt(
1350 metric_name: &str,
1351 goal: &str,
1352 goal_description: Option<&str>,
1353 best_value: Option<f64>,
1354 history: &[RoundRecord],
1355 attempt_memory: Option<&str>,
1356 last_verdict_json: &str,
1357 crate_diff: &str,
1358 build_errors: Option<&str>,
1359 note: Option<&str>,
1360) -> String {
1361 let mut s = round_context_prompt(
1362 metric_name,
1363 goal,
1364 goal_description,
1365 best_value,
1366 history,
1367 attempt_memory,
1368 last_verdict_json,
1369 crate_diff,
1370 build_errors,
1371 note,
1372 );
1373
1374 s.push_str(
1375 "\nReturn the experiment plan now (do not edit files). Make it specific \
1376 enough for the coding model to implement verbatim, and include a one-line \
1377 summary, the source scheduler you borrowed from, the files/functions to \
1378 change, the exact mechanism, and the expected effect on the metric. If a \
1379 tool errors or is missing, keep going and still return the plan.\n",
1380 );
1381
1382 s
1383}
1384
1385#[allow(clippy::too_many_arguments)]
1389fn edit_prompt(
1390 metric_name: &str,
1391 goal: &str,
1392 goal_description: Option<&str>,
1393 best_value: Option<f64>,
1394 history: &[RoundRecord],
1395 attempt_memory: Option<&str>,
1396 last_verdict_json: &str,
1397 crate_diff: &str,
1398 build_errors: Option<&str>,
1399 note: Option<&str>,
1400 plan: Option<&str>,
1401) -> String {
1402 match plan.filter(|p| !p.trim().is_empty()) {
1403 Some(plan) => format!(
1405 "Implement this experiment with the edit_file tool, then reply with a \
1406 one-line summary of the change.\n\n{}",
1407 prefix_at_char_boundary(plan, 6000).trim()
1408 ),
1409 None => {
1411 let mut s = round_context_prompt(
1412 metric_name,
1413 goal,
1414 goal_description,
1415 best_value,
1416 history,
1417 attempt_memory,
1418 last_verdict_json,
1419 crate_diff,
1420 build_errors,
1421 note,
1422 );
1423 s.push_str(
1424 "\nMake the edit you believe will improve the metric now (or fix \
1425 the failure above), then reply with a one-line summary of the change.\n",
1426 );
1427 s
1428 }
1429 }
1430}
1431
1432#[allow(clippy::too_many_arguments)]
1436fn knob_planner_prompt(
1437 metric_name: &str,
1438 goal: &str,
1439 goal_description: Option<&str>,
1440 best_value: Option<f64>,
1441 history: &[RoundRecord],
1442 attempt_memory: Option<&str>,
1443 last_verdict_json: &str,
1444 crate_diff: &str,
1445 note: Option<&str>,
1446) -> String {
1447 let mut s = round_context_prompt(
1448 metric_name,
1449 goal,
1450 goal_description,
1451 best_value,
1452 history,
1453 attempt_memory,
1454 last_verdict_json,
1455 crate_diff,
1456 None,
1457 note,
1458 );
1459 s.push_str(&format!(
1460 "\nPick the single most promising UNTESTED option value and return the \
1461 plan now (do not edit files): name the option, the exact new default \
1462 value, and the one-line `default_value`/`default_value_t` edit in \
1463 `src/main.rs` the coder should make. If every option value worth trying \
1464 has already been tested, reply with exactly `{KNOB_PHASE_DONE_SENTINEL}` \
1465 and nothing else.\n"
1466 ));
1467 s
1468}
1469
1470#[allow(clippy::too_many_arguments)]
1475fn knob_edit_prompt(
1476 metric_name: &str,
1477 goal: &str,
1478 goal_description: Option<&str>,
1479 best_value: Option<f64>,
1480 history: &[RoundRecord],
1481 attempt_memory: Option<&str>,
1482 last_verdict_json: &str,
1483 crate_diff: &str,
1484 note: Option<&str>,
1485 plan: Option<&str>,
1486 inventory: &str,
1487) -> String {
1488 const GUARDRAIL: &str = "Add or edit only the option's clap default in the \
1489 `Opts` struct in `src/main.rs` (the `default_value_t`/`default_value` \
1490 attribute). Do NOT touch the `to_bpf()` match arms or any BPF code.";
1491 match plan.filter(|p| !p.trim().is_empty()) {
1492 Some(plan) => format!(
1493 "Apply this knob change with the edit_file tool, then reply with a \
1494 one-line summary. {GUARDRAIL}\n\n{}",
1495 prefix_at_char_boundary(plan, 6000).trim()
1496 ),
1497 None => {
1498 let mut s = round_context_prompt(
1499 metric_name,
1500 goal,
1501 goal_description,
1502 best_value,
1503 history,
1504 attempt_memory,
1505 last_verdict_json,
1506 crate_diff,
1507 None,
1508 note,
1509 );
1510 let inv = inventory.trim();
1511 if !inv.is_empty() {
1512 s.push_str("\n## Available configuration options\n");
1513 s.push_str(inv);
1514 s.push('\n');
1515 }
1516 s.push_str(&format!(
1517 "\nRetune ONE existing option to a single untested value you \
1518 expect will improve the metric, then reply with a one-line \
1519 summary. {GUARDRAIL}\n"
1520 ));
1521 s
1522 }
1523 }
1524}
1525
1526#[allow(clippy::too_many_arguments)]
1527async fn build_with_fix_loop(
1528 args: &OptimizeArgs,
1529 build_fix_attempts: u32,
1530 source: &Path,
1531 package: &str,
1532 profile: &str,
1533 crate_dir: &str,
1534 baseline_sha: &str,
1535 metric_name: &str,
1536 goal: &str,
1537 goal_description: Option<&str>,
1538 best_value: f64,
1539 history: &[RoundRecord],
1540 attempt_memory: Option<&str>,
1541 last_verdict_json: &str,
1542 client: &reqwest::Client,
1543 models: &config::ModelRoles,
1544 coding_system: &str,
1545 scheduler_cwd: &Path,
1546 edit_tl: &api::ToolLoopConfig,
1547 stdout_color: color::Style,
1548 stderr_color: color::Style,
1549 active_model: &mut ActiveModelPrinter,
1550 total_usage: &mut usage::Usage,
1551 turn_timeout: Duration,
1552 interrupted: Arc<AtomicBool>,
1553) -> Result<Option<String>> {
1554 let mut build_err = cargo_build_with_progress(
1555 source,
1556 package,
1557 profile,
1558 !args.json,
1559 stdout_color,
1560 &interrupted,
1561 )
1562 .err();
1563 let mut fix = 0;
1564 if interrupted_requested(&interrupted) {
1565 return Ok(build_err.or_else(|| Some("interrupted by Ctrl-C".to_string())));
1566 }
1567 while build_err.is_some() && fix < build_fix_attempts && !interrupted_requested(&interrupted) {
1568 fix += 1;
1569 agent_warn(
1570 stderr_color,
1571 format!("build failed; fix attempt {fix}/{}", build_fix_attempts),
1572 );
1573 let diff_now = git::diff(source, baseline_sha, crate_dir).unwrap_or_default();
1574 let up = edit_prompt(
1575 metric_name,
1576 goal,
1577 goal_description,
1578 Some(best_value),
1579 history,
1580 attempt_memory,
1581 last_verdict_json,
1582 &diff_now,
1583 build_err.as_deref(),
1584 None,
1585 None,
1586 );
1587 let before_fix = normalize_diff(&diff_now);
1588 let fix_turn = run_coding_turn(
1589 client,
1590 models,
1591 coding_system,
1592 &up,
1593 scheduler_cwd,
1594 edit_tl,
1595 args.verbose,
1596 !args.json,
1597 stderr_color,
1598 active_model,
1599 total_usage,
1600 turn_timeout,
1601 interrupted.clone(),
1602 )
1603 .await;
1604 let fix_attempted = match fix_turn {
1605 Err(e) if interrupt::is(&e) => {
1606 return Ok(Some("interrupted by Ctrl-C".to_string()));
1607 }
1608 Err(e) => return Err(e),
1609 Ok(Ok(_)) => true,
1610 Ok(Err(msg)) => {
1611 let after_fix =
1612 normalize_diff(&git::diff(source, baseline_sha, crate_dir).unwrap_or_default());
1613 if after_fix != before_fix {
1614 agent_warn(
1615 stderr_color,
1616 format!("API error after build-fix edit; retrying build: {msg}"),
1617 );
1618 true
1619 } else {
1620 agent_error(
1621 stderr_color,
1622 format!("API error during build-fix attempt; reverting round: {msg}"),
1623 );
1624 false
1625 }
1626 }
1627 };
1628 if !fix_attempted {
1629 break;
1630 }
1631 build_err = cargo_build_with_progress(
1632 source,
1633 package,
1634 profile,
1635 !args.json,
1636 stdout_color,
1637 &interrupted,
1638 )
1639 .err();
1640 }
1641 Ok(build_err)
1642}
1643
1644#[tokio::main]
1645async fn main() -> Result<()> {
1646 optimize(OptimizeArgs::parse()).await
1647}
1648
1649async fn optimize(args: OptimizeArgs) -> Result<()> {
1650 let stderr_color = color::Style::stderr(args.no_color);
1651 let stdout_color = color::Style::stdout(args.no_color);
1652 let source = resolve_repo_root(&args.source)?;
1653
1654 let default_spec = source.join("tools/scx_forge_agent/spec.toml");
1659 let spec_path = args.spec.clone().unwrap_or_else(|| default_spec.clone());
1660 if !spec_path.is_file() {
1661 anyhow::bail!(
1662 "spec not found: {}\nThe default spec is {}; pass --spec or create it.",
1663 spec_path.display(),
1664 default_spec.display(),
1665 );
1666 }
1667 let spec = spec::Spec::load(&spec_path)
1668 .with_context(|| format!("load spec {}", spec_path.display()))?;
1669 let package = spec.scheduler.package.clone();
1670 if package.trim().is_empty() {
1671 anyhow::bail!("spec [scheduler].package is empty; set a crate name, e.g. scx_cosmos");
1672 }
1673 let profile = spec.scheduler.profile.clone();
1674 let accept_threshold_stddev = spec.goal.accept_threshold_stddev;
1675 let crate_dir = args
1678 .crate_dir
1679 .clone()
1680 .unwrap_or_else(|| format!("scheds/rust/{package}"));
1681 let crate_dir_abs = source.join(&crate_dir);
1682 if !crate_dir_abs.is_dir() {
1683 anyhow::bail!("crate dir not found: {}", crate_dir_abs.display());
1684 }
1685 let scheds_root = if spec.ai.cross_scheduler_refs {
1689 let scheds_root = source.join("scheds/rust");
1690 scheds_root.is_dir().then_some(scheds_root)
1691 } else {
1692 None
1693 };
1694 let models = config::resolve_roles_from_env(
1698 spec.ai.backend.as_deref(),
1699 spec.ai.model.as_deref(),
1700 spec.ai.coding_backend.as_deref(),
1701 spec.ai.coding_model.as_deref(),
1702 )?;
1703 let attempt_memory = match args.resume.as_deref() {
1704 Some(path) => read_attempt_memory(path)?,
1705 None => None,
1706 };
1707
1708 let planner_system = planner_system_prompt(
1709 &package,
1710 &crate_dir,
1711 scheduler_help_text(&source, &package, &profile).as_deref(),
1712 scheds_root.is_some(),
1713 );
1714 let coding_system = coding_system_prompt(&package, &crate_dir, scheds_root.is_some());
1715
1716 if args.dry_run {
1718 let sample_plan_user = planner_prompt(
1719 "<metric>",
1720 "<goal>",
1721 Some("<plain-language goal from spec [goal].prompt>"),
1722 None,
1723 &[],
1724 attempt_memory.as_deref(),
1725 "{ (a baseline run would appear here) }",
1726 "",
1727 None,
1728 None,
1729 );
1730 let sample_edit_user = edit_prompt(
1731 "<metric>",
1732 "<goal>",
1733 Some("<plain-language goal from spec [goal].prompt>"),
1734 None,
1735 &[],
1736 attempt_memory.as_deref(),
1737 "{ (a baseline run would appear here) }",
1738 "",
1739 None,
1740 None,
1741 Some("<planner output would appear here>"),
1742 );
1743 println!("=== resolved paths ===");
1744 println!("source : {}", source.display());
1745 println!("package : {package}");
1746 println!("crate_dir : {}", crate_dir_abs.display());
1747 println!(
1748 "scheds_root : {}",
1749 scheds_root
1750 .as_ref()
1751 .map(|p| p.display().to_string())
1752 .unwrap_or_else(|| "(cross-scheduler refs disabled)".to_string())
1753 );
1754 println!("profile : {profile}");
1755 println!(
1756 "planner : {} ({})",
1757 model_display_name(&models.planner),
1758 model_backend_label(&models.planner)
1759 );
1760 println!(
1761 "coding : {} ({})",
1762 model_display_name(&models.coding),
1763 model_backend_label(&models.coding)
1764 );
1765 println!("cargo : {}", cargo_program().to_string_lossy());
1766 println!("spec : {}", spec_path.display());
1767 println!(
1768 "tracing : {}",
1769 if spec.tracing.enable_tracing {
1770 "enabled when perf is available"
1771 } else {
1772 "disabled"
1773 }
1774 );
1775 println!(
1776 "resume file : {}",
1777 args.resume
1778 .as_ref()
1779 .map(|p| p.display().to_string())
1780 .unwrap_or_else(|| "(disabled)".to_string())
1781 );
1782 println!(
1783 "save file : {}",
1784 args.save
1785 .as_ref()
1786 .map(|p| p.display().to_string())
1787 .unwrap_or_else(|| "(disabled)".to_string())
1788 );
1789 println!(
1790 "keep-running : {}",
1791 if args.keep_running {
1792 "enabled"
1793 } else {
1794 "disabled"
1795 }
1796 );
1797 println!(
1798 "attempt memory: {}",
1799 attempt_memory
1800 .as_ref()
1801 .map(|s| format!("{} bytes loaded", s.len()))
1802 .unwrap_or_else(|| "(none loaded)".to_string())
1803 );
1804 println!(
1805 "sudo pass file: {}",
1806 spec_sudo_password_file(&spec_path, &spec)
1807 .map(|p| {
1808 p.map(|p| p.display().to_string())
1809 .unwrap_or_else(|| "(not configured)".to_string())
1810 })
1811 .unwrap_or_else(|e| format!("ERROR: {e:#}"))
1812 );
1813 println!("rounds : {}", spec.ai.rounds);
1814 println!(
1815 "\n=== planner system prompt ({} chars) ===",
1816 planner_system.len()
1817 );
1818 println!("{planner_system}");
1819 println!(
1820 "\n=== coding system prompt ({} chars) ===",
1821 coding_system.len()
1822 );
1823 println!("{coding_system}");
1824 println!("\n=== example planner user prompt ===\n{sample_plan_user}");
1825 println!("\n=== example coding user prompt ===\n{sample_edit_user}");
1826 let refs_tools = if scheds_root.is_some() {
1827 "; read-only scheduler-reference tools: list_schedulers/grep_schedulers/read_scheduler_file"
1828 } else {
1829 ""
1830 };
1831 let keep_running_step = if args.keep_running {
1832 "\n 6. restore the final accepted source, rebuild it, print the report, then exec the scheduler in the foreground"
1833 } else {
1834 ""
1835 };
1836 println!(
1837 "\n=== planned loop ===\n\
1838 round 0: validate baseline (current crate) -> seed best value & objective\n\
1839 each round 1..={}:\n 1. planner/reasoner chooses ONE experiment with read-only tools (openai backend, including host topology tools lscpu_e/numactl_hardware/cpu_cache_sizes)\n \
1840 2. coding model applies the edit (target tools: rg when available/grep/read_file/list_dir/edit_file{refs_tools}; host topology tools: lscpu_e/numactl_hardware/cpu_cache_sizes)\n \
1841 3. {} build --profile {} -p {} (fix loop up to {} attempts)\n \
1842 4. built-in harness builds+runs spec {} -> verdict (runtime-fix loop up to {} attempts)\n \
1843 5. keep iff improves by > {} * stddev (stage edit in place), else revert (restore from last accepted){keep_running_step}\n\
1844 The crate is edited in place on the current branch (no branch, no commits); the\n\
1845 winning variant is left as uncommitted working-tree changes.\n",
1846 spec.ai.rounds,
1847 cargo_program().to_string_lossy(),
1848 profile,
1849 package,
1850 spec.ai.build_fix_attempts,
1851 spec_path.display(),
1852 spec.ai.runtime_fix_attempts,
1853 accept_threshold_stddev,
1854 );
1855 return Ok(());
1856 }
1857
1858 configure_sudo_from_spec(&spec_path, &spec)?;
1862 let sudo = sudo::Sudo::resolve()?;
1863
1864 let client = http::build_http_client()?;
1865 let interrupted = Arc::new(AtomicBool::new(false));
1866
1867 git::ensure_clean(&source, &crate_dir)?;
1871 let baseline_sha = git::rev_parse(&source, "HEAD")?;
1872
1873 install_ctrl_c_handler(
1877 interrupted.clone(),
1878 stderr_color,
1879 args.json,
1880 source.clone(),
1881 crate_dir.clone(),
1882 );
1883
1884 agent_info(
1885 stderr_color,
1886 format!(
1887 "editing {} in place (baseline {})",
1888 crate_dir,
1889 &baseline_sha[..baseline_sha.len().min(12)]
1890 ),
1891 );
1892
1893 let run = optimize_loop(
1894 &args,
1895 &source,
1896 &spec_path,
1897 &spec,
1898 &package,
1899 &profile,
1900 &crate_dir,
1901 &sudo,
1902 &client,
1903 &models,
1904 &baseline_sha,
1905 scheds_root.as_deref(),
1906 attempt_memory.as_deref(),
1907 interrupted.clone(),
1908 );
1909 let result = run.await;
1910
1911 match result {
1912 Ok(outcome) => {
1913 let OptimizeOutcome {
1914 report: rep,
1915 interrupted: was_interrupted,
1916 } = outcome;
1917 git::discard(&source, &crate_dir).ok();
1921 git::unstage(&source, &crate_dir).ok();
1922 if let Some(save_path) = args.save.as_deref() {
1923 let attempt_summary =
1924 run_attempt_summary(&rep, &package, &crate_dir, &spec_path, &baseline_sha);
1925 append_attempt_summary(save_path, &attempt_summary, stdout_color, args.json)?;
1926 }
1927 let start_keep_running = args.keep_running && !was_interrupted;
1928 if args.keep_running && was_interrupted {
1929 agent_warn(
1930 stderr_color,
1931 "--keep-running skipped because the optimization run was interrupted",
1932 );
1933 }
1934 if start_keep_running {
1935 build_keep_running_scheduler(
1936 &source,
1937 &package,
1938 &profile,
1939 stdout_color,
1940 stderr_color,
1941 args.json,
1942 &interrupted,
1943 )?;
1944 }
1945 print_final_report(&rep, was_interrupted, args.json, stdout_color, &crate_dir)?;
1946 if start_keep_running {
1947 start_keep_running_scheduler(&source, &package, &profile, &sudo, stderr_color)?;
1948 }
1949 Ok(())
1950 }
1951 Err(e) => {
1952 git::discard(&source, &crate_dir).ok();
1955 git::unstage(&source, &crate_dir).ok();
1956 Err(e)
1957 }
1958 }
1959}
1960
1961#[allow(clippy::too_many_arguments)]
1962async fn optimize_loop(
1963 args: &OptimizeArgs,
1964 source: &Path,
1965 spec_path: &Path,
1966 spec: &spec::Spec,
1967 package: &str,
1968 profile: &str,
1969 crate_dir: &str,
1970 sudo: &sudo::Sudo,
1971 client: &reqwest::Client,
1972 models: &config::ModelRoles,
1973 baseline_sha: &str,
1974 scheds_root: Option<&Path>,
1975 attempt_memory: Option<&str>,
1976 interrupted: Arc<AtomicBool>,
1977) -> Result<OptimizeOutcome> {
1978 let stderr_color = color::Style::stderr(args.no_color);
1979 let stdout_color = color::Style::stdout(args.no_color);
1980 let coding_system = coding_system_prompt(package, crate_dir, scheds_root.is_some());
1981 let model_turn_timeout = spec.ai.turn_timeout();
1982
1983 agent_info(stderr_color, "round 0: measuring baseline...");
1985 let base = validate::run_validation(
1986 source,
1987 spec_path,
1988 sudo,
1989 args.verbose,
1990 (!args.json).then_some(stdout_color),
1991 spec.tracing.enable_tracing,
1992 &interrupted,
1993 )?;
1994 if interrupted_requested(&interrupted) {
1995 return Ok(OptimizeOutcome {
1996 report: Report {
1997 objective: format!("{} {}", base.goal, base.metric_name),
1998 goal: base.goal,
1999 metric_name: base.metric_name,
2000 start_value: None,
2001 best_value: None,
2002 best_round: None,
2003 rounds: Vec::new(),
2004 usage: usage::Usage::default(),
2005 },
2006 interrupted: true,
2007 });
2008 }
2009 if !base.is_complete() {
2010 anyhow::bail!(
2011 "baseline does not build/attach/measure (stage={}); fix the scheduler before optimizing.\nerrors: {:?}",
2012 base.stage, base.errors
2013 );
2014 }
2015 let help_text = scheduler_help_text(source, package, profile);
2021 let accept_threshold_stddev = spec.goal.accept_threshold_stddev;
2022 let planner_system = planner_system_prompt(
2023 package,
2024 crate_dir,
2025 help_text.as_deref(),
2026 scheds_root.is_some(),
2027 );
2028 let knob_inventory = knob_inventory(source, package, profile);
2029 let knob_system = knob_system_prompt(package, crate_dir, &knob_inventory);
2030 let metric_name = base.metric_name.clone();
2031 let goal = base.goal.clone();
2032 let goal_description = spec.goal.prompt.clone();
2034 let mut best_value = base.median.or(base.value).unwrap();
2035 let start_value = best_value;
2036
2037 let mut rep = Report {
2038 objective: format!("{goal} {metric_name}"),
2039 goal: goal.clone(),
2040 metric_name: metric_name.clone(),
2041 start_value: Some(start_value),
2042 best_value: Some(best_value),
2043 best_round: None,
2044 rounds: Vec::new(),
2045 usage: usage::Usage::default(),
2046 };
2047 let mut last_verdict_json = serde_json::to_string(&base.raw).unwrap_or_default();
2048 agent_success(
2049 stderr_color,
2050 format!("baseline {metric_name} = {best_value:.3} (goal: {goal})"),
2051 );
2052
2053 let mut total_usage = usage::Usage::default();
2055 let mut active_model = ActiveModelPrinter::new(!args.json, stdout_color);
2056
2057 let mut run_interrupted = interrupted_requested(&interrupted);
2058 if run_interrupted {
2059 rep.usage = total_usage;
2060 return Ok(OptimizeOutcome {
2061 report: rep,
2062 interrupted: true,
2063 });
2064 }
2065
2066 let mut phase = if spec.ai.skip_knobs {
2072 Phase::Mechanism
2073 } else {
2074 Phase::Knob
2075 };
2076
2077 for round in 1..=spec.ai.rounds {
2078 if interrupted_requested(&interrupted) {
2079 run_interrupted = true;
2080 break;
2081 }
2082
2083 agent_info(stderr_color, format!("round {round}/{}", spec.ai.rounds));
2084 let crate_diff = git::diff(source, baseline_sha, crate_dir).unwrap_or_default();
2085
2086 let scheduler_cwd = source.join(crate_dir);
2092 let planning_tl = api::tool_loop(
2093 &scheduler_cwd,
2094 scheds_root.as_deref(),
2095 false,
2096 spec.ai.max_tool_iterations,
2097 );
2098 let edit_tl = api::tool_loop(
2099 &scheduler_cwd,
2100 scheds_root.as_deref(),
2101 true,
2102 spec.ai.max_tool_iterations,
2103 );
2104 let accepted_norm = normalize_diff(&crate_diff);
2105 let derive_summary = |content: &str| -> String {
2106 if content.trim().is_empty() {
2107 "(no summary)".to_string()
2108 } else {
2109 content.lines().next().unwrap_or("").trim().to_string()
2110 }
2111 };
2112 let mut note: Option<String> = None;
2113 let summary;
2114 let mut made_edit;
2115 let mut ineffective_tries = 0u32;
2116 let mut api_tries = 0u32;
2117 let mut planner_retries = 0u32;
2118 let mut api_error: Option<String> = None;
2119 let mut model_timeout_err: Option<String> = None;
2120 let mut ineffective_edit: Option<String> = None;
2121
2122 if phase == Phase::Knob {
2123 agent_info(stderr_color, format!("round {round}: knob-tuning phase"));
2124 }
2125
2126 loop {
2127 let plan = if models.planner.backend.is_subprocess()
2134 && models.planner.same_endpoint(&models.coding)
2135 {
2136 None
2137 } else {
2138 let knob_phase = phase == Phase::Knob;
2139 let up = if knob_phase {
2140 knob_planner_prompt(
2141 &metric_name,
2142 &goal,
2143 goal_description.as_deref(),
2144 Some(best_value),
2145 &rep.rounds,
2146 attempt_memory,
2147 &last_verdict_json,
2148 &crate_diff,
2149 note.as_deref(),
2150 )
2151 } else {
2152 planner_prompt(
2153 &metric_name,
2154 &goal,
2155 goal_description.as_deref(),
2156 Some(best_value),
2157 &rep.rounds,
2158 attempt_memory,
2159 &last_verdict_json,
2160 &crate_diff,
2161 None,
2162 note.as_deref(),
2163 )
2164 };
2165 let system = if knob_phase {
2166 &knob_system
2167 } else {
2168 &planner_system
2169 };
2170 active_model.use_model("planner", &models.planner);
2171 let planner_result = if models.planner.backend.is_subprocess() {
2174 agent_cli::run(
2175 &models.planner,
2176 agent_cli::Mode::Plan,
2177 !args.json,
2178 system,
2179 &up,
2180 &scheduler_cwd,
2181 args.verbose,
2182 stderr_color,
2183 &mut total_usage,
2184 model_turn_timeout,
2185 interrupted.clone(),
2186 )
2187 .await
2188 } else {
2189 let stream_plan = !args.json;
2190 api::chat(
2191 client,
2192 &models.planner,
2193 system,
2194 &up,
2195 Some(&planning_tl),
2196 args.verbose,
2197 stderr_color,
2198 stream_plan,
2199 &mut total_usage,
2200 model_turn_timeout,
2201 interrupted.clone(),
2202 )
2203 .await
2204 };
2205 match planner_result {
2206 Ok(plan) => {
2207 if knob_phase && knob_phase_complete(&plan) {
2211 agent_info(
2212 stderr_color,
2213 "knob phase complete (model signaled exhaustion); switching to code-change phase",
2214 );
2215 phase = Phase::Mechanism;
2216 note = None;
2217 continue;
2218 }
2219 if let Some(reason) = planner_plan_rejection_reason(&plan) {
2220 const MAX_PLANNER_RECOVERY_RETRIES: u32 = 2;
2221 if planner_retries < MAX_PLANNER_RECOVERY_RETRIES {
2222 planner_retries += 1;
2223 agent_warn(
2224 stderr_color,
2225 format!(
2226 "planner did not produce a usable plan; retrying planner turn {planner_retries}/{MAX_PLANNER_RECOVERY_RETRIES}: {reason}"
2227 ),
2228 );
2229 note = Some(planner_recovery_note(reason));
2230 continue;
2231 }
2232 agent_warn(
2233 stderr_color,
2234 format!(
2235 "planner still did not produce a usable plan after retries; continuing to coding without planner output: {reason}"
2236 ),
2237 );
2238 None
2239 } else {
2240 Some(plan)
2241 }
2242 }
2243 Err(e) if interrupt::is(&e) => {
2244 summary = "interrupted during planner turn".to_string();
2245 made_edit = false;
2246 break;
2247 }
2248 Err(e) if model_timeout::is(&e) => {
2249 let msg = model_timeout::summary(&e);
2250 summary = format!("planner model turn timeout: {msg}");
2251 made_edit = false;
2252 model_timeout_err = Some(msg);
2253 break;
2254 }
2255 Err(e) if api::is_api_error(&e) => {
2256 let msg = api::error_summary(&e);
2257 const MAX_API_ERROR_RETRIES: u32 = 2;
2258 if api_tries < MAX_API_ERROR_RETRIES {
2259 api_tries += 1;
2260 agent_warn(
2261 stderr_color,
2262 format!(
2263 "planner API error before edit; retrying round attempt {api_tries}/{MAX_API_ERROR_RETRIES}: {msg}"
2264 ),
2265 );
2266 continue;
2267 }
2268 summary = format!("planner api error: {msg}");
2269 made_edit = false;
2270 api_error = Some(msg);
2271 break;
2272 }
2273 Err(e) => return Err(e),
2274 }
2275 };
2276
2277 let up = if phase == Phase::Knob {
2278 knob_edit_prompt(
2279 &metric_name,
2280 &goal,
2281 goal_description.as_deref(),
2282 Some(best_value),
2283 &rep.rounds,
2284 attempt_memory,
2285 &last_verdict_json,
2286 &crate_diff,
2287 note.as_deref(),
2288 plan.as_deref(),
2289 &knob_inventory,
2290 )
2291 } else {
2292 edit_prompt(
2293 &metric_name,
2294 &goal,
2295 goal_description.as_deref(),
2296 Some(best_value),
2297 &rep.rounds,
2298 attempt_memory,
2299 &last_verdict_json,
2300 &crate_diff,
2301 None,
2302 note.as_deref(),
2303 plan.as_deref(),
2304 )
2305 };
2306 let coding_turn = run_coding_turn(
2307 client,
2308 models,
2309 &coding_system,
2310 &up,
2311 &scheduler_cwd,
2312 &edit_tl,
2313 args.verbose,
2314 !args.json,
2315 stderr_color,
2316 &mut active_model,
2317 &mut total_usage,
2318 model_turn_timeout,
2319 interrupted.clone(),
2320 )
2321 .await;
2322 let content = match coding_turn {
2323 Err(e) if interrupt::is(&e) => {
2324 let d = normalize_diff(
2325 &git::diff(source, baseline_sha, crate_dir).unwrap_or_default(),
2326 );
2327 summary = "interrupted during coding turn".to_string();
2328 made_edit = d != accepted_norm;
2329 break;
2330 }
2331 Err(e) if model_timeout::is(&e) => {
2332 let d = normalize_diff(
2333 &git::diff(source, baseline_sha, crate_dir).unwrap_or_default(),
2334 );
2335 let msg = model_timeout::summary(&e);
2336 summary = format!("coding model turn timeout: {msg}");
2337 made_edit = d != accepted_norm;
2338 model_timeout_err = Some(msg);
2339 break;
2340 }
2341 Err(e) => return Err(e),
2342 Ok(Ok(content)) => content,
2343 Ok(Err(msg)) => {
2344 let d = normalize_diff(
2345 &git::diff(source, baseline_sha, crate_dir).unwrap_or_default(),
2346 );
2347 made_edit = d != accepted_norm;
2348 if made_edit {
2349 agent_warn(
2350 stderr_color,
2351 format!("API error after edit; continuing with current diff: {msg}"),
2352 );
2353 summary = format!("api error after edit; validating partial edit: {msg}");
2354 } else {
2355 const MAX_API_ERROR_RETRIES: u32 = 2;
2356 if api_tries < MAX_API_ERROR_RETRIES {
2357 api_tries += 1;
2358 agent_warn(
2359 stderr_color,
2360 format!(
2361 "API error before edit; retrying round attempt {api_tries}/{MAX_API_ERROR_RETRIES}: {msg}"
2362 ),
2363 );
2364 continue;
2365 }
2366 summary = format!("api error: {msg}");
2367 api_error = Some(msg);
2368 }
2369 break;
2370 }
2371 };
2372 let s = derive_summary(&content);
2373 let d = normalize_diff(&git::diff(source, baseline_sha, crate_dir).unwrap_or_default());
2374 made_edit = d != accepted_norm;
2375 if !made_edit {
2376 summary = s;
2377 break;
2378 }
2379 let current_round_diff = git::worktree_diff(source, crate_dir).unwrap_or_default();
2380 if let Some(reason) = ineffective_round_edit_reason(¤t_round_diff) {
2381 if ineffective_tries < 2 {
2382 ineffective_tries += 1;
2383 agent_warn(
2384 stderr_color,
2385 format!(
2386 "ineffective edit rejected; re-prompting ({ineffective_tries}/2): {reason}"
2387 ),
2388 );
2389 git::discard(source, crate_dir).ok();
2390 note = Some(ineffective_round_note(&reason));
2391 continue;
2392 }
2393 summary = format!("ineffective edit: {s}");
2394 ineffective_edit = Some(reason);
2395 break;
2396 }
2397 summary = s;
2400 break;
2401 }
2402
2403 let summary = if phase == Phase::Knob {
2407 format!("knob: {summary}")
2408 } else {
2409 summary
2410 };
2411
2412 agent_dim(
2415 stderr_color,
2416 format!("usage so far: {}", total_usage.footer_line()),
2417 );
2418
2419 if interrupted_requested(&interrupted) {
2420 run_interrupted = true;
2421 record_interrupted_round(&mut rep, source, crate_dir, round, summary, &goal);
2422 break;
2423 }
2424
2425 if let Some(err) = model_timeout_err.take() {
2426 agent_warn(
2427 stderr_color,
2428 format!("model turn timed out; skipping round: {err}"),
2429 );
2430 if !args.json {
2431 println!(
2432 "{}",
2433 stdout_color.yellow(format!("round {round}: model turn timeout (skipped)"))
2434 );
2435 }
2436 record_model_timeout_round(
2437 &mut rep, source, crate_dir, round, summary, &goal, made_edit,
2438 );
2439 continue;
2440 }
2441
2442 if made_edit && ineffective_edit.is_none() {
2443 let current_round_diff = git::worktree_diff(source, crate_dir).unwrap_or_default();
2444 ineffective_edit = ineffective_round_edit_reason(¤t_round_diff);
2445 }
2446
2447 if let Some(err) = api_error {
2448 agent_error(stderr_color, format!("API error; skipping round: {err}"));
2449 if !args.json {
2450 println!(
2451 "{}",
2452 stdout_color.red(format!("round {round}: API error (skipped)"))
2453 );
2454 }
2455 let attempt_diff = if made_edit {
2456 git::worktree_diff(source, crate_dir).unwrap_or_default()
2457 } else {
2458 String::new()
2459 };
2460 git::discard(source, crate_dir).ok();
2461 rep.rounds.push(round_record(
2462 round,
2463 summary,
2464 "api-error",
2465 None,
2466 None,
2467 false,
2468 &goal,
2469 &attempt_diff,
2470 ));
2471 continue;
2475 }
2476
2477 if let Some(reason) = ineffective_edit {
2478 agent_warn(
2479 stderr_color,
2480 format!("ineffective edit; skipping round: {reason}"),
2481 );
2482 if !args.json {
2483 println!(
2484 "{}",
2485 stdout_color.yellow(format!(
2486 "round {round}: (ineffective edit skipped: {reason})"
2487 ))
2488 );
2489 }
2490 let attempt_diff = git::worktree_diff(source, crate_dir).unwrap_or_default();
2491 git::discard(source, crate_dir).ok();
2492 rep.rounds.push(round_record(
2493 round,
2494 summary,
2495 "ineffective",
2496 None,
2497 None,
2498 false,
2499 &goal,
2500 &attempt_diff,
2501 ));
2502 continue;
2506 }
2507
2508 if !made_edit {
2509 agent_warn(stderr_color, "model made no edit; skipping round");
2510 if !args.json {
2511 println!(
2512 "{}",
2513 stdout_color.yellow(format!("round {round}: (no edit made)"))
2514 );
2515 }
2516 git::discard(source, crate_dir).ok();
2517 rep.rounds.push(round_record(
2518 round, summary, "no-edit", None, None, false, &goal, "",
2519 ));
2520 continue;
2524 }
2525
2526 if !args.json && models.coding.backend.is_subprocess() {
2529 println!("{}", stdout_color.bold(format!("round {round}: {summary}")));
2530 }
2531
2532 let build_err = build_with_fix_loop(
2534 args,
2535 spec.ai.build_fix_attempts,
2536 source,
2537 package,
2538 profile,
2539 crate_dir,
2540 baseline_sha,
2541 &metric_name,
2542 &goal,
2543 goal_description.as_deref(),
2544 best_value,
2545 &rep.rounds,
2546 attempt_memory,
2547 &last_verdict_json,
2548 client,
2549 models,
2550 &coding_system,
2551 &scheduler_cwd,
2552 &edit_tl,
2553 stdout_color,
2554 stderr_color,
2555 &mut active_model,
2556 &mut total_usage,
2557 model_turn_timeout,
2558 interrupted.clone(),
2559 )
2560 .await;
2561 let build_err = match build_err {
2562 Ok(build_err) => build_err,
2563 Err(e) if interrupt::is(&e) => {
2564 run_interrupted = true;
2565 record_interrupted_round(&mut rep, source, crate_dir, round, summary, &goal);
2566 break;
2567 }
2568 Err(e) if model_timeout::is(&e) => {
2569 let msg = model_timeout::summary(&e);
2570 agent_warn(
2571 stderr_color,
2572 format!("model turn timeout during build fix; skipping round: {msg}"),
2573 );
2574 if !args.json {
2575 println!(
2576 "{}",
2577 stdout_color.yellow(format!(
2578 "round {round}: model turn timeout during build fix (skipped)"
2579 ))
2580 );
2581 }
2582 record_model_timeout_round(
2583 &mut rep, source, crate_dir, round, summary, &goal, true,
2584 );
2585 continue;
2586 }
2587 Err(e) => return Err(e),
2588 };
2589 if interrupted_requested(&interrupted) {
2590 run_interrupted = true;
2591 record_interrupted_round(&mut rep, source, crate_dir, round, summary, &goal);
2592 break;
2593 }
2594 if build_err.is_some() {
2595 agent_error(stderr_color, "build still failing; reverting round");
2596 if !args.json {
2597 println!(
2598 "{}",
2599 stdout_color.red(format!(" round {round} result: build failed (reverted)"))
2600 );
2601 }
2602 let attempt_diff = git::worktree_diff(source, crate_dir).unwrap_or_default();
2603 git::discard(source, crate_dir).ok();
2604 rep.rounds.push(round_record(
2605 round,
2606 summary,
2607 "build-failed",
2608 None,
2609 None,
2610 false,
2611 &goal,
2612 &attempt_diff,
2613 ));
2614 continue;
2618 }
2619
2620 let verdict = validate::run_validation(
2622 source,
2623 spec_path,
2624 sudo,
2625 args.verbose,
2626 (!args.json).then_some(stdout_color),
2627 spec.tracing.enable_tracing,
2628 &interrupted,
2629 )?;
2630 let mut verdict = verdict;
2631 last_verdict_json = serde_json::to_string(&verdict.raw).unwrap_or_default();
2632 if interrupted_requested(&interrupted) {
2633 run_interrupted = true;
2634 record_interrupted_round(&mut rep, source, crate_dir, round, summary, &goal);
2635 break;
2636 }
2637 let mut runtime_fix = 0;
2638 let mut runtime_fix_build_failed = false;
2639 while should_try_runtime_fix(&verdict)
2640 && runtime_fix < spec.ai.runtime_fix_attempts
2641 && !interrupted_requested(&interrupted)
2642 {
2643 runtime_fix += 1;
2644 agent_warn(
2645 stderr_color,
2646 format!(
2647 "validation stage={}; runtime-fix attempt {runtime_fix}/{}",
2648 verdict.stage, spec.ai.runtime_fix_attempts
2649 ),
2650 );
2651 let diff_now = git::diff(source, baseline_sha, crate_dir).unwrap_or_default();
2652 let feedback = validation_failure_feedback(&verdict);
2653 let up = edit_prompt(
2654 &metric_name,
2655 &goal,
2656 goal_description.as_deref(),
2657 Some(best_value),
2658 &rep.rounds,
2659 attempt_memory,
2660 &last_verdict_json,
2661 &diff_now,
2662 Some(&feedback),
2663 None,
2664 None,
2665 );
2666 let before_fix = normalize_diff(&diff_now);
2667 let fix_turn = run_coding_turn(
2668 client,
2669 models,
2670 &coding_system,
2671 &up,
2672 &scheduler_cwd,
2673 &edit_tl,
2674 args.verbose,
2675 !args.json,
2676 stderr_color,
2677 &mut active_model,
2678 &mut total_usage,
2679 model_turn_timeout,
2680 interrupted.clone(),
2681 )
2682 .await;
2683 let fix_attempted = match fix_turn {
2684 Err(e) if interrupt::is(&e) => {
2685 run_interrupted = true;
2686 break;
2687 }
2688 Err(e) if model_timeout::is(&e) => {
2689 model_timeout_err = Some(model_timeout::summary(&e));
2690 break;
2691 }
2692 Err(e) => return Err(e),
2693 Ok(Ok(_)) => true,
2694 Ok(Err(msg)) => {
2695 let after_fix = normalize_diff(
2696 &git::diff(source, baseline_sha, crate_dir).unwrap_or_default(),
2697 );
2698 if after_fix != before_fix {
2699 agent_warn(
2700 stderr_color,
2701 format!("API error after runtime-fix edit; retrying validation: {msg}"),
2702 );
2703 true
2704 } else {
2705 agent_error(
2706 stderr_color,
2707 format!("API error during runtime-fix attempt; reverting round: {msg}"),
2708 );
2709 false
2710 }
2711 }
2712 };
2713 if !fix_attempted {
2714 break;
2715 }
2716
2717 let build_err = build_with_fix_loop(
2718 args,
2719 spec.ai.build_fix_attempts,
2720 source,
2721 package,
2722 profile,
2723 crate_dir,
2724 baseline_sha,
2725 &metric_name,
2726 &goal,
2727 goal_description.as_deref(),
2728 best_value,
2729 &rep.rounds,
2730 attempt_memory,
2731 &last_verdict_json,
2732 client,
2733 models,
2734 &coding_system,
2735 &scheduler_cwd,
2736 &edit_tl,
2737 stdout_color,
2738 stderr_color,
2739 &mut active_model,
2740 &mut total_usage,
2741 model_turn_timeout,
2742 interrupted.clone(),
2743 )
2744 .await;
2745 let build_err = match build_err {
2746 Ok(build_err) => build_err,
2747 Err(e) if interrupt::is(&e) => {
2748 run_interrupted = true;
2749 break;
2750 }
2751 Err(e) if model_timeout::is(&e) => {
2752 model_timeout_err = Some(model_timeout::summary(&e));
2753 break;
2754 }
2755 Err(e) => return Err(e),
2756 };
2757 if build_err.is_some() {
2758 runtime_fix_build_failed = true;
2759 break;
2760 }
2761
2762 verdict = validate::run_validation(
2763 source,
2764 spec_path,
2765 sudo,
2766 args.verbose,
2767 (!args.json).then_some(stdout_color),
2768 spec.tracing.enable_tracing,
2769 &interrupted,
2770 )?;
2771 last_verdict_json = serde_json::to_string(&verdict.raw).unwrap_or_default();
2772 }
2773 if interrupted_requested(&interrupted) {
2774 run_interrupted = true;
2775 record_interrupted_round(&mut rep, source, crate_dir, round, summary, &goal);
2776 break;
2777 }
2778 if let Some(err) = model_timeout_err.take() {
2779 agent_warn(
2780 stderr_color,
2781 format!("model turn timeout during runtime fix; skipping round: {err}"),
2782 );
2783 if !args.json {
2784 println!(
2785 "{}",
2786 stdout_color.yellow(format!(
2787 "round {round}: model turn timeout during runtime fix (skipped)"
2788 ))
2789 );
2790 }
2791 record_model_timeout_round(&mut rep, source, crate_dir, round, summary, &goal, true);
2792 continue;
2793 }
2794 if runtime_fix_build_failed {
2795 agent_error(
2796 stderr_color,
2797 "build failed while fixing runtime failure; reverting round",
2798 );
2799 if !args.json {
2800 println!(
2801 "{}",
2802 stdout_color.red(format!(" round {round} result: build failed (reverted)"))
2803 );
2804 }
2805 let attempt_diff = git::worktree_diff(source, crate_dir).unwrap_or_default();
2806 git::discard(source, crate_dir).ok();
2807 rep.rounds.push(round_record(
2808 round,
2809 summary,
2810 "build-failed",
2811 None,
2812 None,
2813 false,
2814 &goal,
2815 &attempt_diff,
2816 ));
2817 continue;
2821 }
2822 if !verdict.is_complete() {
2823 let outcome_label = match verdict.stage.as_str() {
2824 "build" => "build-failed",
2825 "attach" | "preflight" => "attach-failed",
2826 "runtime" | "metric" => "runtime-failed",
2827 _ => "metric-failed",
2828 };
2829 agent_error(
2830 stderr_color,
2831 format!("validation stage={} -> reverting", verdict.stage),
2832 );
2833 let attempt_diff = git::worktree_diff(source, crate_dir).unwrap_or_default();
2834 git::discard(source, crate_dir).ok();
2835 rep.rounds.push(round_record(
2836 round,
2837 summary,
2838 outcome_label,
2839 None,
2840 None,
2841 false,
2842 &goal,
2843 &attempt_diff,
2844 ));
2845 continue;
2849 }
2850
2851 let value = verdict.median.or(verdict.value).unwrap();
2853 let stddev = verdict.stddev.unwrap_or(0.0);
2854 let margin = accept_threshold_stddev * stddev;
2855 let improved = if goal == "minimize" {
2856 value < best_value - margin
2857 } else {
2858 value > best_value + margin
2859 };
2860 let delta = value - best_value;
2861
2862 let attempt_diff = git::worktree_diff(source, crate_dir).unwrap_or_default();
2863 if improved {
2864 git::checkpoint(source, crate_dir).with_context(|| "stage accepted variant")?;
2865 best_value = value;
2866 rep.best_value = Some(best_value);
2867 rep.best_round = Some(round);
2868 rep.rounds.push(round_record(
2869 round,
2870 summary,
2871 "kept",
2872 Some(value),
2873 Some(delta),
2874 true,
2875 &goal,
2876 &attempt_diff,
2877 ));
2878 agent_success(
2879 stderr_color,
2880 format!("KEPT: {metric_name} = {value:.3} (Δ {delta:+.3})"),
2881 );
2882 } else {
2883 git::discard(source, crate_dir).ok();
2884 rep.rounds.push(round_record(
2885 round,
2886 summary,
2887 "reverted",
2888 Some(value),
2889 Some(delta),
2890 false,
2891 &goal,
2892 &attempt_diff,
2893 ));
2894 agent_warn(
2895 stderr_color,
2896 format!("reverted: {metric_name} = {value:.3} (Δ {delta:+.3}, margin {margin:.3})"),
2897 );
2898 }
2899 }
2900
2901 if interrupted_requested(&interrupted) {
2902 run_interrupted = true;
2903 }
2904 rep.usage = total_usage;
2905 Ok(OptimizeOutcome {
2906 report: rep,
2907 interrupted: run_interrupted,
2908 })
2909}
2910
2911#[cfg(test)]
2912mod tests {
2913 use super::*;
2914
2915 #[test]
2916 fn parses_spec_toml_alias() {
2917 let args = OptimizeArgs::try_parse_from([
2918 "scx-forge-agent",
2919 "--spec-toml",
2920 "/tmp/custom.spec.toml",
2921 ])
2922 .unwrap();
2923
2924 assert_eq!(
2925 args.spec.as_deref(),
2926 Some(Path::new("/tmp/custom.spec.toml"))
2927 );
2928 }
2929
2930 #[test]
2931 fn parses_save_and_resume() {
2932 let args = OptimizeArgs::try_parse_from([
2933 "scx-forge-agent",
2934 "--save",
2935 "/tmp/save.md",
2936 "--resume",
2937 "/tmp/resume.md",
2938 ])
2939 .unwrap();
2940
2941 assert_eq!(args.save.as_deref(), Some(Path::new("/tmp/save.md")));
2942 assert_eq!(args.resume.as_deref(), Some(Path::new("/tmp/resume.md")));
2943 }
2944
2945 #[test]
2946 fn parses_keep_running() {
2947 let args = OptimizeArgs::try_parse_from(["scx-forge-agent", "--keep-running"]).unwrap();
2948
2949 assert!(args.keep_running);
2950 }
2951
2952 #[test]
2953 fn expands_tilde_in_sudo_passwd_file() {
2954 let old_home = std::env::var_os("HOME");
2955 let home = std::env::temp_dir().join(format!("scx_home_test_{}", std::process::id()));
2956 std::fs::create_dir_all(&home).unwrap();
2957 std::env::set_var("HOME", &home);
2958
2959 let path =
2960 expand_sudo_password_file(Path::new("/tmp/spec.toml"), Path::new("~/.pass")).unwrap();
2961 assert_eq!(path, home.join(".pass"));
2962
2963 match old_home {
2964 Some(v) => std::env::set_var("HOME", v),
2965 None => std::env::remove_var("HOME"),
2966 }
2967 let _ = std::fs::remove_dir_all(&home);
2968 }
2969
2970 #[test]
2971 fn expands_relative_wildcard_in_sudo_passwd_file() {
2972 let base = std::env::temp_dir().join(format!("scx_glob_test_{}", std::process::id()));
2973 std::fs::create_dir_all(base.join("secrets")).unwrap();
2974 let pass = base.join("secrets/sudo-pass");
2975 std::fs::write(&pass, "pw\n").unwrap();
2976
2977 let path = expand_sudo_password_file(&base.join("spec.toml"), Path::new("secrets/sudo-*"))
2978 .unwrap();
2979 assert_eq!(path, pass);
2980
2981 let _ = std::fs::remove_dir_all(&base);
2982 }
2983
2984 #[test]
2985 fn rejects_ambiguous_sudo_passwd_file_wildcard() {
2986 let base =
2987 std::env::temp_dir().join(format!("scx_glob_ambiguous_test_{}", std::process::id()));
2988 std::fs::create_dir_all(base.join("secrets")).unwrap();
2989 std::fs::write(base.join("secrets/a.pass"), "pw\n").unwrap();
2990 std::fs::write(base.join("secrets/b.pass"), "pw\n").unwrap();
2991
2992 let err = expand_sudo_password_file(&base.join("spec.toml"), Path::new("secrets/*.pass"))
2993 .unwrap_err()
2994 .to_string();
2995 assert!(err.contains("ambiguous"), "got: {err}");
2996
2997 let _ = std::fs::remove_dir_all(&base);
2998 }
2999
3000 #[test]
3001 fn optimizer_mission_is_neutral_and_knob_aware() {
3002 let optimizer_md = OPTIMIZER_MD
3003 .split_whitespace()
3004 .collect::<Vec<_>>()
3005 .join(" ");
3006 let has = |phrase: &str| {
3007 optimizer_md.contains(&phrase.split_whitespace().collect::<Vec<_>>().join(" "))
3008 };
3009
3010 assert!(has("wakeup frequency"));
3015 assert!(!has("explore the other schedulers"));
3016 assert!(!has("read_scheduler_file"));
3017 assert!(!has("list_schedulers"));
3018 assert!(has("Do not re-implement an existing knob"));
3021 assert!(has("changes the default value of an existing option"));
3022 assert!(!has("DSQ topology"));
3023 assert!(!has("idle-CPU policy"));
3024 }
3025
3026 #[test]
3027 fn coder_prompt_is_just_the_plan() {
3028 let plan = "Summary: add wakeup-frequency-weighted deadlines.\nEdit task_dsq_key() in src/bpf/main.bpf.c.";
3029 let prompt = edit_prompt(
3030 "latency",
3031 "minimize",
3032 Some("reduce benchmark latency"),
3033 Some(10.0),
3034 &[],
3035 None,
3036 "{}",
3037 "",
3038 None,
3039 None,
3040 Some(plan),
3041 );
3042 assert!(prompt.contains(plan));
3045 assert!(prompt.contains("Implement this experiment with the edit_file tool"));
3046 assert!(!prompt.contains("Optimization history"));
3047 assert!(!prompt.contains("Last benchmark result"));
3048 assert!(!prompt.contains("Currently applied changes"));
3049 }
3050
3051 #[test]
3052 fn planner_prompt_does_not_request_edits() {
3053 let prompt = planner_prompt(
3054 "latency",
3055 "minimize",
3056 Some("reduce benchmark latency"),
3057 Some(10.0),
3058 &[],
3059 None,
3060 "{}",
3061 "",
3062 None,
3063 None,
3064 );
3065
3066 assert!(prompt.contains("Return the experiment plan now"));
3067 assert!(prompt.contains("do not edit files"));
3068 assert!(!prompt.contains("Implement this experiment with the edit_file tool"));
3069 }
3070
3071 #[test]
3072 fn planner_plan_rejection_catches_empty_and_tool_errors() {
3073 assert_eq!(
3074 planner_plan_rejection_reason(" "),
3075 Some("empty planner response")
3076 );
3077 assert_eq!(
3078 planner_plan_rejection_reason("ERROR: unknown tool: str_replace_editor"),
3079 Some("planner returned a tool error instead of a plan")
3080 );
3081 assert_eq!(
3082 planner_plan_rejection_reason(
3083 "Summary: switch enqueue to a per-LLC DSQ and update dispatch to pull from the same LLC queue."
3084 ),
3085 None
3086 );
3087 }
3088
3089 #[test]
3090 fn planner_system_prompt_is_planner_only() {
3091 let prompt = planner_system_prompt("scx_forge", "scheds/rust/scx_forge", None, false);
3094 assert!(prompt.contains("You are the PLANNER"));
3095 assert!(prompt.contains("Do not edit files"));
3096 assert!(!prompt.contains("scheds/rust`"));
3097 assert!(!prompt.contains("list_schedulers"));
3098
3099 let refs = planner_system_prompt("scx_forge", "scheds/rust/scx_forge", None, true);
3102 assert!(refs.contains("You are the PLANNER"));
3103 assert!(refs.contains("cross-scheduler reference tools are enabled"));
3104 assert!(refs.contains("read_scheduler_file"));
3105 }
3106
3107 #[test]
3108 fn planner_system_prompt_lists_existing_knobs_from_help() {
3109 let help = "Options:\n --dsq-topology <V> topology\n --ordering <V> ordering";
3110 let prompt = planner_system_prompt("scx_forge", "scheds/rust/scx_forge", Some(help), false);
3111 assert!(prompt.contains("## Already-implemented options (do NOT re-propose these)"));
3112 assert!(prompt.contains("--dsq-topology"));
3113 assert!(prompt.contains("--ordering"));
3114 let bare = planner_system_prompt("scx_forge", "scheds/rust/scx_forge", None, false);
3117 assert!(!bare.contains("## Already-implemented options (do NOT re-propose these)"));
3118 }
3119
3120 #[test]
3121 fn prompt_includes_previous_attempt_memory() {
3122 let prompt = planner_prompt(
3123 "score",
3124 "maximize",
3125 Some("increase throughput"),
3126 Some(10.0),
3127 &[],
3128 Some("round 1 | reverted | bad placement idea"),
3129 "{}",
3130 "",
3131 None,
3132 None,
3133 );
3134
3135 assert!(prompt.contains("Previous run attempt memory"));
3136 assert!(prompt.contains("bad placement idea"));
3137 }
3138
3139 #[test]
3140 fn run_attempt_summary_records_rounds() {
3141 let mut rep = Report {
3142 objective: "maximize score".into(),
3143 goal: "maximize".into(),
3144 metric_name: "score".into(),
3145 start_value: Some(10.0),
3146 best_value: Some(12.0),
3147 best_round: Some(1),
3148 rounds: Vec::new(),
3149 usage: usage::Usage::default(),
3150 };
3151 rep.rounds.push(round_record(
3152 1,
3153 "Bias wakeup CPU placement".into(),
3154 "kept",
3155 Some(12.0),
3156 Some(2.0),
3157 true,
3158 "maximize",
3159 "select_cpu",
3160 ));
3161
3162 let summary = run_attempt_summary(
3163 &rep,
3164 "scx_forge",
3165 "scheds/rust/scx_forge",
3166 Path::new("spec.toml"),
3167 "abc123",
3168 );
3169
3170 assert!(summary.contains("Package: `scx_forge`"));
3171 assert!(summary.contains("| 1 | kept | 12.000 | +2.000 |"));
3172 assert!(summary.contains("Bias wakeup CPU placement"));
3173 }
3174
3175 #[test]
3176 fn prompts_keep_history_neutral_after_failed_rounds() {
3177 let history = vec![round_record(
3178 1,
3179 "reduced timeslice by 10%".into(),
3180 "reverted",
3181 Some(11.0),
3182 Some(1.0),
3183 false,
3184 "minimize",
3185 "",
3186 )];
3187 let prompt = edit_prompt(
3188 "latency",
3189 "minimize",
3190 Some("reduce benchmark latency"),
3191 Some(10.0),
3192 &history,
3193 None,
3194 "{}",
3195 "",
3196 None,
3197 None,
3198 None,
3199 );
3200
3201 assert!(prompt.contains("Optimization history"));
3202 assert!(!prompt.contains("Cooldown"));
3205 assert!(!prompt.contains("DIFFERENT policy seam"));
3206 assert!(!prompt.contains("do not answer with another timeslice/constant adjustment"));
3207 assert!(!prompt.contains("compose and adapt a reusable structural policy concept"));
3208 }
3209
3210 #[test]
3211 fn knob_phase_complete_detects_sentinel_only_as_full_line() {
3212 assert!(knob_phase_complete("KNOB_PHASE_COMPLETE"));
3214 assert!(knob_phase_complete("done tuning\nKNOB_PHASE_COMPLETE\n"));
3215 assert!(knob_phase_complete(" KNOB_PHASE_COMPLETE "));
3216 assert!(!knob_phase_complete(
3218 "Set ordering default to Deadline; this is not KNOB_PHASE_COMPLETE yet"
3219 ));
3220 assert!(!knob_phase_complete("Set idle_policy default to Waker."));
3221 }
3222
3223 #[test]
3224 fn knob_system_prompt_constrains_to_clap_defaults_and_lists_options() {
3225 let inventory = "## Command-line options (from --help)\n```\n--ordering <ORDERING> [possible values: vruntime, deadline, fifo]\n```\n";
3226 let prompt = knob_system_prompt("scx_forge", "scheds/rust/scx_forge", inventory);
3227 let prompt_words = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
3228 assert!(prompt.contains("KNOB-TUNING phase"));
3230 assert!(prompt.contains("Add or change only"));
3231 assert!(prompt.contains("default_value_t"));
3232 assert!(prompt.contains("optional string options"));
3233 assert!(prompt.contains("to_bpf()"));
3234 assert!(prompt.contains("KNOB_PHASE_COMPLETE"));
3235 assert!(prompt.contains("Bias knob selection by workload saturation"));
3236 assert!(prompt.contains("placement-style knobs"));
3237 assert!(prompt.contains("ordering-style knobs"));
3238 assert!(prompt_words.contains("saturated, unsaturated, or mixed"));
3239 assert!(prompt_words.contains("CPU capacity, fast/slow CPU preference"));
3240 assert!(prompt_words.contains("primary / performance domains"));
3241 assert!(!KNOB_MD.contains("KNOB_PHASE"));
3242 assert!(prompt.contains("possible values: vruntime, deadline, fifo"));
3244 }
3245
3246 #[test]
3247 fn knob_edit_prompt_carries_inventory_only_without_a_plan() {
3248 let inventory = "OPTION_INVENTORY_MARKER";
3249 let with_plan = knob_edit_prompt(
3252 "latency",
3253 "minimize",
3254 None,
3255 Some(10.0),
3256 &[],
3257 None,
3258 "{}",
3259 "",
3260 None,
3261 Some("Set ordering default to Deadline"),
3262 inventory,
3263 );
3264 assert!(with_plan.contains("Set ordering default to Deadline"));
3265 assert!(with_plan.contains("to_bpf()"));
3266 assert!(!with_plan.contains(inventory));
3267 let no_plan = knob_edit_prompt(
3270 "latency",
3271 "minimize",
3272 None,
3273 Some(10.0),
3274 &[],
3275 None,
3276 "{}",
3277 "",
3278 None,
3279 None,
3280 inventory,
3281 );
3282 assert!(no_plan.contains(inventory));
3283 }
3284
3285 #[test]
3286 fn classify_attempt_tags_policy_area_and_direction() {
3287 let tags = classify_attempt(
3288 "Bias wakeup CPU placement toward idle siblings",
3289 "diff --git a/src/bpf/main.bpf.c b/src/bpf/main.bpf.c\n+select_cpu_idle_sibling();",
3290 );
3291
3292 assert_eq!(tags.policy_area, "placement");
3293 assert!(tags.direction.contains("bias"));
3294 assert!(tags.direction.contains("wakeup"));
3295 }
3296
3297 #[test]
3298 fn detects_bpf_const_volatile_initializer_only_diff() {
3299 let diff = r#"diff --git a/scheds/rust/scx_forge/src/bpf/main.bpf.c b/scheds/rust/scx_forge/src/bpf/main.bpf.c
3300index 1d6827ca2..179c31d0d 100644
3301--- a/scheds/rust/scx_forge/src/bpf/main.bpf.c
3302+++ b/scheds/rust/scx_forge/src/bpf/main.bpf.c
3303@@ -48,7 +48,7 @@ const volatile bool debug;
3304 /*
3305 * Default task time slice.
3306 */
3307-const volatile u64 slice_ns = NSEC_PER_MSEC;
3308+const volatile u64 slice_ns = NSEC_PER_MSEC / 2;
3309
3310 /*
3311 * SMT (Simultaneous Multi-Threading) is enabled on the system.
3312"#;
3313
3314 let reason = ineffective_round_edit_reason(diff).unwrap();
3315 assert!(reason.contains("slice_ns"));
3316 assert!(reason.contains("Rust rodata"));
3317 }
3318
3319 #[test]
3320 fn allows_bpf_const_volatile_diff_when_rust_is_changed_too() {
3321 let diff = r#"diff --git a/scheds/rust/scx_forge/src/bpf/main.bpf.c b/scheds/rust/scx_forge/src/bpf/main.bpf.c
3322--- a/scheds/rust/scx_forge/src/bpf/main.bpf.c
3323+++ b/scheds/rust/scx_forge/src/bpf/main.bpf.c
3324@@ -48,7 +48,7 @@
3325-const volatile u64 slice_ns = NSEC_PER_MSEC;
3326+const volatile u64 slice_ns = NSEC_PER_MSEC / 2;
3327diff --git a/scheds/rust/scx_forge/src/main.rs b/scheds/rust/scx_forge/src/main.rs
3328--- a/scheds/rust/scx_forge/src/main.rs
3329+++ b/scheds/rust/scx_forge/src/main.rs
3330@@ -164,7 +164,7 @@
3331- rodata.slice_ns = opts.slice_us * 1000;
3332+ rodata.slice_ns = opts.slice_us * 500;
3333"#;
3334
3335 assert!(ineffective_round_edit_reason(diff).is_none());
3336 }
3337
3338 #[test]
3339 fn allows_bpf_policy_logic_diff() {
3340 let diff = r#"diff --git a/scheds/rust/scx_forge/src/bpf/main.bpf.c b/scheds/rust/scx_forge/src/bpf/main.bpf.c
3341--- a/scheds/rust/scx_forge/src/bpf/main.bpf.c
3342+++ b/scheds/rust/scx_forge/src/bpf/main.bpf.c
3343@@ -342,7 +342,7 @@
3344- return slice_ns;
3345+ return slice_ns / 2;
3346"#;
3347
3348 assert!(ineffective_round_edit_reason(diff).is_none());
3349 }
3350}