diff --git a/crates/cm-api/src/topology_exec.rs b/crates/cm-api/src/topology_exec.rs index 45b23e7..c682fab 100644 --- a/crates/cm-api/src/topology_exec.rs +++ b/crates/cm-api/src/topology_exec.rs @@ -915,7 +915,47 @@ pub fn compose_turn_prompt( // is also what the scorer reads the arm back from. Two copies of this // sentence is two chances for the reader to stop recognising the writer. let preamble = crate::skill_delivery::preamble(mode); - format!("{base}\n\n# Your skills\n\n{preamble}\n\n{skills}") + let section = format!("# Your skills\n\n{preamble}\n\n{skills}"); + // After the identity paragraph and BEFORE the task. This section is the + // one part of the prompt that asks the agent to do something before it + // starts — read a procedure — and until 2026-09-18 it was appended last, + // after the task, the tool list, the workspace rules and the marker + // contract, sitting 87–90% of the way into a 6 KB prompt. On the three + // `index`-arm runs the narratives never mentioned it at all. Position is + // the untested lever; this is the test. + match base.split_once("\n\n") { + Some((identity, rest)) if identity.starts_with("You are ") => { + format!("{identity}\n\n{section}\n\n{rest}") + } + _ => format!("{section}\n\n{base}"), + } +} + +#[cfg(test)] +mod prompt_order_tests { + use super::compose_turn_prompt; + use crate::skill_delivery::Mode; + + /// The section comes right after the identity paragraph, before the task — + /// not appended after everything else. + #[test] + fn skills_come_after_identity_and_before_the_task() { + let base = "You are the \"x\" agent. Do your part.\n\nTask: MISSION: y\n\nTOOLS AVAILABLE"; + let p = compose_turn_prompt(base, Some("--- SKILL: a ---\nbody"), Mode::Files); + let i_id = p.find("You are the").unwrap(); + let i_sk = p.find("# Your skills").unwrap(); + let i_task = p.find("Task: MISSION").unwrap(); + assert!(i_id < i_sk && i_sk < i_task, "order was identity={i_id} skills={i_sk} task={i_task}\n{p}"); + assert!(p.ends_with("TOOLS AVAILABLE"), "the base's tail is untouched"); + } + + /// A base with no identity paragraph still gets the section first. + #[test] + fn skills_lead_when_there_is_no_identity_paragraph() { + let p = compose_turn_prompt("Task: y", Some("--- SKILL: a ---\nbody"), Mode::Files); + assert!(p.starts_with("# Your skills"), "{p}"); + assert!(p.ends_with("Task: y")); + } } /// Parse `role=alias,role=alias` into a map (blank/malformed entries skipped).