Skip to main content

recursive/
system_prompt.rs

1//! Unified system-prompt assembly.
2//!
3//! Every agent-loop entry point (CLI `run` / `loop`, HTTP API, TUI) calls
4//! [`assemble_system_prompt`] to build the final system prompt from a
5//! channel-prepared base. This is the single maintenance point for the
6//! "common" prompt structure; channels only differ in how they source the
7//! base (e.g. CLI `--append-system-prompt`, HTTP `append_system_prompt`
8//! request field) and fold that into `base` before calling.
9//!
10//! Layer order (stable → volatile, to maximise prefix-cache hits):
11//! 1. `# Project context` — `AGENTS.md` + `CLAUDE.md` at workspace root
12//! 2. `base` — `default_system_prompt()` + 6 memory layers (already assembled
13//!    in `Config::from_env`) + any channel `append_system_prompt`
14//! 3. `# Available skills` — `skill_index()`
15//! 4. `## Sub-agents enabled` + sub-agent usage note — only when
16//!    `sub_agent_enabled` (the `Agent` tool is registered in parallel by
17//!    `multi::register_subagent_if_enabled`)
18
19use std::path::Path;
20
21use crate::config::prepend_project_context;
22use crate::skills::{skill_index, Skill};
23
24/// Owned, structured view of an assembled system prompt.
25///
26/// `full` is the exact, byte-identical concatenation produced by the
27/// pre-goal-328 implementation of [`assemble_system_prompt`] — every
28/// existing call site (8 production + 5 test sites) can use it via
29/// [`AssembledPrompt::full`] / [`AssembledPrompt::into_full`] without
30/// changing the assembled string itself (a hard requirement: prefix-cache
31/// stability depends on it, and `ordering_project_context_then_base_then_skills_then_subagent`
32/// must still pass unchanged).
33///
34/// `segments` is the per-bucket breakdown the local [`crate::llm::ContextBreakdown`]
35/// estimator reads from. Each segment is the exact text that contributes
36/// to its bucket, in the same join order as `full`.
37#[derive(Debug, Clone)]
38pub struct AssembledPrompt {
39    /// The fully-joined system prompt. Byte-identical to what the
40    /// pre-goal-328 implementation returned.
41    pub full: String,
42    /// Per-segment substrings for breakdown estimation.
43    pub segments: PromptSegments,
44}
45
46impl AssembledPrompt {
47    /// Borrow the fully-joined system prompt as a `&str`.
48    pub fn full(&self) -> &str {
49        &self.full
50    }
51
52    /// Consume self and return the underlying owned `String`.
53    pub fn into_full(self) -> String {
54        self.full
55    }
56}
57
58impl std::fmt::Display for AssembledPrompt {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.write_str(&self.full)
61    }
62}
63
64/// Per-bucket substrings of an [`AssembledPrompt`]. Each field is the
65/// exact text that contributes to that breakdown bucket; join order
66/// matches [`AssembledPrompt::full`].
67///
68/// Empty fields are normal: e.g. `rules` is `""` when neither
69/// `AGENTS.md` nor `CLAUDE.md` exists, `skills` is `""` when the
70/// skill index is empty, and `subagents` is `""` when
71/// `sub_agent_enabled` is false. The breakdown estimator uses the
72/// raw length (chars/4) of each segment to size its bucket.
73#[derive(Debug, Clone, Default)]
74pub struct PromptSegments {
75    /// `# Project context` + `AGENTS.md` + `CLAUDE.md` (or `""` when
76    /// neither file exists — `prepend_project_context` short-circuits).
77    pub rules: String,
78    /// The base prompt: `default_system_prompt()` + the six memory
79    /// layers + any channel `append_system_prompt` suffix.
80    pub system_prompt: String,
81    /// `skill_index()` output (or `""` when no skills are loaded).
82    pub skills: String,
83    /// `## Sub-agents enabled` + sub-agent usage note (or `""` when
84    /// `sub_agent_enabled` is false).
85    pub subagents: String,
86}
87
88/// Assemble the final system prompt for an agent run.
89///
90/// Returns an [`AssembledPrompt`] whose `full` field is byte-identical to
91/// what the pre-goal-328 implementation returned. Existing call sites
92/// obtain the joined string via `.full()` / `.into_full()` (the
93/// `Display` impl also writes the joined text). New code (the local
94/// [`crate::llm::ContextBreakdown`] estimator) reads `.segments` to
95/// size each bucket.
96///
97/// `base` is the channel-prepared base prompt (already containing the
98/// hardcoded working principles, the six memory layers from
99/// `Config::from_env`, and any `append_system_prompt` text the channel
100/// folded in). `workspace` is used to read `AGENTS.md` / `CLAUDE.md`.
101/// `skills` drives the skill index. When `sub_agent_enabled` is true
102/// the coordinator workflow prompt and the `sub_agent` usage note are
103/// appended — the matching `Agent` tool must be registered by the
104/// caller via `multi::register_subagent_if_enabled`.
105pub fn assemble_system_prompt(
106    base: &str,
107    workspace: &Path,
108    skills: &[Skill],
109    sub_agent_enabled: bool,
110) -> AssembledPrompt {
111    // ── Rules segment ────────────────────────────────────────────────────
112    // `prepend_project_context` either wraps `base` with a `# Project
113    // context` header + AGENTS.md/CLAUDE.md + `---` separator, or returns
114    // `base` unchanged. We replicate that exact byte sequence so the
115    // breakdown's `rules` bucket can be subtracted back out of `full`
116    // to recover the unwrapped `base` (the `system_prompt` bucket).
117    let segments = PromptSegments {
118        rules: project_context_block(workspace),
119        system_prompt: base.to_string(),
120        skills: skill_index(skills),
121        subagents: if sub_agent_enabled {
122            // The full coordinator guidance (how/when to delegate, writing
123            // worker prompts, continuation, verification) lives on the `agent`
124            // tool's own description so it travels with the tool and doesn't
125            // bloat the base system prompt. Here we only plant a one-line
126            // pointer so the model knows delegation is available.
127            "\n\n---\n\n## Sub-agents enabled\n\n\
128             The `agent` tool is registered. See its description for how to \
129             delegate, continue, and verify worker output."
130                .to_string()
131        } else {
132            String::new()
133        },
134    };
135
136    // ── Joined full prompt ───────────────────────────────────────────────
137    // Mirrors `prepend_project_context` byte-for-byte:
138    //   - when no AGENTS.md / CLAUDE.md: `full = base + skills + subagents`
139    //   - when at least one is present: `full = rules_segment + base + skills + subagents`
140    // where `rules_segment` is exactly what `prepend_project_context` produced
141    // (so the existing prepended string's prefix matches byte-for-byte).
142    let mut full = prepend_project_context(base, workspace);
143
144    // NOTE: the skill catalog is intentionally NOT inlined into `full`.
145    // It is volatile and long, so inlining it would break prefix-cache
146    // stability on every skill change. It ships instead as a per-turn
147    // `system-reminder` via `crate::skills::skill_reminder` (see
148    // `crate::run_core::call_llm`). `segments.skills` is still computed
149    // above so the prompt-breakdown estimator can account for its tokens.
150    if !segments.subagents.is_empty() {
151        full.push_str(&segments.subagents);
152    }
153
154    AssembledPrompt { full, segments }
155}
156
157/// Build the rules-segment text (the `# Project context` prefix that
158/// `prepend_project_context` prepends to `base`, or empty when neither
159/// `AGENTS.md` nor `CLAUDE.md` exists).
160///
161/// Mirrors [`crate::config::prepend_project_context`] so the segment's
162/// length matches the bytes it contributes to `full`. Returns `""` when
163/// `load_project_context` would return `None`.
164fn project_context_block(workspace: &Path) -> String {
165    match crate::config::load_project_context(workspace) {
166        Some(ctx) => format!("# Project context\n\n{ctx}\n\n---\n\n"),
167        None => String::new(),
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::skills::SkillMode;
175    use std::path::PathBuf;
176
177    fn make_skill(name: &str, desc: &str) -> Skill {
178        Skill {
179            name: name.to_string(),
180            description: desc.to_string(),
181            path: PathBuf::from(format!("/tmp/skills/{name}/SKILL.md")),
182            mode: SkillMode::Manual,
183            triggers: vec![],
184            hint: String::new(),
185            depends_on: vec![],
186            refs: vec![],
187            params: vec![],
188            scripts: vec![],
189            sections: vec![],
190            globs: None,
191        }
192    }
193
194    #[test]
195    fn no_files_no_skills_no_subagent_returns_base() {
196        let tmp = tempfile::tempdir().expect("tempdir");
197        let out = assemble_system_prompt("BASE", tmp.path(), &[], false).into_full();
198        assert_eq!(out, "BASE");
199    }
200
201    #[test]
202    fn prepends_agents_and_claude_sections() {
203        let tmp = tempfile::tempdir().expect("tempdir");
204        std::fs::write(tmp.path().join("AGENTS.md"), "agents-body").expect("write");
205        std::fs::write(tmp.path().join("CLAUDE.md"), "claude-body").expect("write");
206
207        let out = assemble_system_prompt("BASE", tmp.path(), &[], false).into_full();
208        assert!(out.starts_with("# Project context\n\n"), "{out}");
209        // AGENTS.md is authoritative; CLAUDE.md is ignored when both exist.
210        assert!(out.contains("## AGENTS.md"), "{out}");
211        assert!(out.contains("agents-body"));
212        assert!(
213            !out.contains("## CLAUDE.md"),
214            "CLAUDE.md must not load when AGENTS.md present: {out}"
215        );
216        assert!(
217            !out.contains("claude-body"),
218            "CLAUDE.md body must not leak: {out}"
219        );
220        assert!(
221            out.contains("\n\n---\n\nBASE"),
222            "base after separator: {out}"
223        );
224    }
225
226    #[test]
227    fn skill_index_not_inlined_into_system_prompt() {
228        let tmp = tempfile::tempdir().expect("tempdir");
229        let skills = vec![make_skill("pdf", "Manipulate PDF documents")];
230        // The skill catalog must NOT be inlined into the static system
231        // prompt — it now ships per-turn as a `system-reminder` via
232        // `skill_reminder()`. This keeps the `system` field stable for
233        // prefix caching.
234        let out = assemble_system_prompt("BASE", tmp.path(), &skills, false).into_full();
235        assert!(!out.contains("Available skills"), "{out}");
236        assert!(!out.contains("pdf"), "{out}");
237        // The catalog is still produced — just via the reminder channel.
238        let reminder = crate::skills::skill_reminder(&skills);
239        assert!(reminder.contains("Available skills"), "{reminder}");
240        assert!(reminder.contains("pdf"), "{reminder}");
241        assert!(reminder.contains("<system-reminder>"), "{reminder}");
242    }
243
244    #[test]
245    fn subagent_suffix_appended_only_when_enabled() {
246        let tmp = tempfile::tempdir().expect("tempdir");
247        let off = assemble_system_prompt("BASE", tmp.path(), &[], false).into_full();
248        assert!(!off.contains("Sub-agents enabled"), "{off}");
249        assert!(
250            !off.contains("`agent` tool is registered"),
251            "sub-agent usage note must not appear when disabled: {off}"
252        );
253
254        let on = assemble_system_prompt("BASE", tmp.path(), &[], true).into_full();
255        assert!(on.contains("## Sub-agents enabled"), "{on}");
256        assert!(on.contains("`agent` tool is registered"), "{on}");
257    }
258
259    #[test]
260    fn ordering_project_context_then_base_then_subagent() {
261        let tmp = tempfile::tempdir().expect("tempdir");
262        std::fs::write(tmp.path().join("AGENTS.md"), "AG").expect("write");
263        let skills = vec![make_skill("xlsx", "Spreadsheets")];
264        let out = assemble_system_prompt("BASE", tmp.path(), &skills, true).into_full();
265
266        let pc = out.find("# Project context").unwrap();
267        let base = out.find("BASE").unwrap();
268        let coord = out.find("Sub-agents enabled").unwrap();
269        // Skill catalog is NOT in the static system prompt (ships as a
270        // per-turn system-reminder), so it must be absent here.
271        assert!(out.find("Available skills").is_none(), "{out}");
272        assert!(pc < base && base < coord, "{out}");
273    }
274
275    // ── Goal-328: byte-identical full, structured segments ───────────────
276
277    /// Compare the `full` field against the byte sequence the pre-goal-328
278    /// implementation produced. This is the regression guard against any
279    /// future refactor silently changing the assembled string (a
280    /// byte-identical `full` is a hard requirement: prefix-cache stability
281    /// depends on it).
282    #[test]
283    fn full_is_stable_and_excludes_skill_index() {
284        // Same fixture as the existing ordering test (the most demanding
285        // path: all four layers present, sub-agent enabled).
286        let tmp = tempfile::tempdir().expect("tempdir");
287        std::fs::write(tmp.path().join("AGENTS.md"), "AG").expect("write");
288        std::fs::write(tmp.path().join("CLAUDE.md"), "CL").expect("write");
289        let skills = vec![make_skill("xlsx", "Spreadsheets")];
290
291        // Reproduce the assembled string. The blocks are concatenated in the
292        // exact order documented in the module header:
293        //   prepend_project_context(base, ws) +
294        //   ("\n" + skill_index) when skills present +
295        //   ("\n\n---\n\n## Sub-agents enabled\n\n...pointer...") when sub-agent enabled.
296        // Note: the full coordinator guidance lives on the `agent` tool
297        // description, NOT in the system prompt — so this segment is just a
298        // one-line pointer.
299        let mut expected = crate::config::prepend_project_context("BASE", tmp.path());
300        // Sanity: the catalog still ships via skill_reminder (not inlined).
301        assert!(
302            crate::skills::skill_reminder(&skills).contains("Available skills"),
303            "skill_reminder must still produce the catalog"
304        );
305        expected.push_str(
306            "\n\n---\n\n## Sub-agents enabled\n\n\
307             The `agent` tool is registered. See its description for how to \
308             delegate, continue, and verify worker output.",
309        );
310
311        let assembled = assemble_system_prompt("BASE", tmp.path(), &skills, true).into_full();
312        assert_eq!(
313            assembled, expected,
314            "AssembledPrompt::full must be stable and exclude the skill catalog"
315        );
316        assert!(
317            !assembled.contains("Available skills"),
318            "skill catalog must not be inlined into full (ships via skill_reminder): {assembled}"
319        );
320    }
321
322    #[test]
323    fn segments_are_populated_and_non_overlapping() {
324        let tmp = tempfile::tempdir().expect("tempdir");
325        std::fs::write(tmp.path().join("AGENTS.md"), "AGENTS body").expect("write");
326        let skills = vec![make_skill("alpha", "first skill")];
327
328        // With both files, skills, and sub-agent enabled, every segment
329        // should be populated.
330        let assembled = assemble_system_prompt("BASE", tmp.path(), &skills, true);
331        assert!(
332            assembled.segments.rules.contains("AGENTS.md"),
333            "rules segment must contain the AGENTS.md heading; got {:?}",
334            assembled.segments.rules
335        );
336        assert!(
337            assembled.segments.rules.contains("AGENTS body"),
338            "rules segment must contain the AGENTS.md body; got {:?}",
339            assembled.segments.rules
340        );
341        assert_eq!(
342            assembled.segments.system_prompt, "BASE",
343            "system_prompt segment must be the unwrapped base"
344        );
345        assert!(
346            assembled.segments.skills.contains("Available skills"),
347            "skills segment must contain the skill index header; got {:?}",
348            assembled.segments.skills
349        );
350        assert!(
351            assembled.segments.skills.contains("alpha"),
352            "skills segment must contain the skill name; got {:?}",
353            assembled.segments.skills
354        );
355        assert!(
356            assembled.segments.subagents.contains("Sub-agents enabled"),
357            "subagents segment must contain the coordinator workflow heading when enabled"
358        );
359
360        // Non-overlapping: rules and system_prompt must not share text. The
361        // `prepend_project_context` integration guarantees rules == bytes
362        // prepended to base, so subtracting `base` from `full` should yield
363        // exactly `rules + skills + subagents`. We assert by checking that
364        // `full` starts with the rules segment (the prepended header) and
365        // that the unwrapped `BASE` substring lives at the boundary
366        // between rules and skills/subagents.
367        assert!(
368            assembled.full.starts_with(&assembled.segments.rules),
369            "full must start with the rules segment; rules={:?} full={:?}",
370            assembled.segments.rules,
371            assembled.full
372        );
373        let base_idx = assembled
374            .full
375            .find("BASE")
376            .expect("full must contain the BASE substring unchanged");
377        assert_eq!(
378            base_idx,
379            assembled.segments.rules.len(),
380            "BASE must start exactly where the rules segment ends"
381        );
382        assert!(
383            assembled.full.contains("AGENTS.md"),
384            "full must contain the AGENTS.md heading from the rules segment"
385        );
386        // The skill catalog is NOT inlined into `full` (it ships via the
387        // `skill_reminder` channel); the `segments.skills` field above still
388        // accounts for its tokens in the breakdown estimator.
389        assert!(
390            !assembled.full.contains("Available skills"),
391            "full must NOT inline the skill catalog: {assembled}"
392        );
393        assert!(
394            assembled.full.contains("Sub-agents enabled"),
395            "full must contain the sub-agent suffix after BASE"
396        );
397    }
398
399    #[test]
400    fn empty_segments_when_layers_absent() {
401        // No AGENTS.md / CLAUDE.md, no skills, sub-agent disabled.
402        let tmp = tempfile::tempdir().expect("tempdir");
403        let assembled = assemble_system_prompt("BASE", tmp.path(), &[], false);
404        assert!(
405            assembled.segments.rules.is_empty(),
406            "rules must be empty when no project context files exist"
407        );
408        assert_eq!(
409            assembled.segments.system_prompt, "BASE",
410            "system_prompt segment must be the base even when rules is empty"
411        );
412        assert!(
413            assembled.segments.skills.is_empty(),
414            "skills must be empty when no skills are loaded"
415        );
416        assert!(
417            assembled.segments.subagents.is_empty(),
418            "subagents must be empty when sub_agent_enabled is false"
419        );
420        // `full` must still equal the base — the byte-identical guarantee.
421        assert_eq!(assembled.full, "BASE");
422    }
423
424    #[test]
425    fn segments_skills_contains_available_skills_only_when_skills_present() {
426        let tmp = tempfile::tempdir().expect("tempdir");
427        let no_skills = assemble_system_prompt("BASE", tmp.path(), &[], false);
428        assert!(
429            !no_skills.segments.skills.contains("Available skills"),
430            "skills segment must not advertise an index when none is loaded"
431        );
432        assert!(
433            no_skills.full == "BASE",
434            "with no skills the joined prompt must equal the base"
435        );
436
437        let skills = vec![make_skill("only", "the only skill")];
438        let with_skills = assemble_system_prompt("BASE", tmp.path(), &skills, false);
439        assert!(
440            with_skills.segments.skills.contains("Available skills"),
441            "skills segment must include the index header when any skill is loaded"
442        );
443    }
444
445    #[test]
446    fn segments_subagents_contains_coordinator_only_when_enabled() {
447        let tmp = tempfile::tempdir().expect("tempdir");
448        let off = assemble_system_prompt("BASE", tmp.path(), &[], false);
449        assert!(
450            !off.segments.subagents.contains("Sub-agents enabled"),
451            "subagents segment must be empty when sub_agent_enabled=false"
452        );
453        let on = assemble_system_prompt("BASE", tmp.path(), &[], true);
454        assert!(
455            on.segments.subagents.contains("Sub-agents enabled"),
456            "subagents segment must contain the coordinator workflow when enabled"
457        );
458    }
459
460    #[test]
461    fn full_accessor_returns_str() {
462        let tmp = tempfile::tempdir().expect("tempdir");
463        let assembled = assemble_system_prompt("BASE", tmp.path(), &[], false);
464        let s: &str = assembled.full();
465        assert_eq!(s, "BASE");
466    }
467
468    #[test]
469    fn display_impl_writes_full() {
470        let tmp = tempfile::tempdir().expect("tempdir");
471        let assembled = assemble_system_prompt("BASE", tmp.path(), &[], false);
472        assert_eq!(format!("{assembled}"), "BASE");
473    }
474}