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