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}