Skip to content

Commit 03a26e9

Browse files
committed
feat(rap-steering-server): embed global steering files into the system prompt
Uses the new `systemPrompt` manifest field: the toolset handler builds a fragment embedding the user's GLOBAL (home-directory) steering files — INFINITY.md/CLAUDE.md-style prose and ~/.kiro/steering/** etc. — each delimited with its `~/` path, rebuilt per manifest fetch so new sessions pick up edits without a server restart. * Project-local steering files are NOT embedded (they require a cloned repo) and stay on the `list_steering`/`load_steering` flow; the fragment says so explicitly. * Structured config (`.json`, e.g. `.claude/settings.json`) is never embedded — not instructions, may contain secrets. * Budgets: 8 KiB per file, 14 KiB total (under the daemon's 16 KiB per-server cap so the runtime never truncates mid-file); oversized files are truncated with a `load_steering` pointer, overflow files are listed for on-demand loading. * Reconciled guidance: `list_steering`'s description and the user-level files note now say global content is usually already embedded, instead of urging a load of every global file.
1 parent 52cda19 commit 03a26e9

4 files changed

Lines changed: 211 additions & 7 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/rap-steering-server/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,6 @@ clap = { version = "4", features = ["derive"] }
2020

2121
[lints]
2222
workspace = true
23+
24+
[dev-dependencies]
25+
tempfile = { workspace = true }

‎crates/rap-steering-server/src/lib.rs‎

Lines changed: 197 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,9 @@ impl SteeringFiles {
101101
}
102102
if !self.user.is_empty() {
103103
sections.push(format!(
104-
"User-level steering files (not project-specific — you should read these \
105-
for the user's global preferences and conventions):\n{}",
104+
"User-level steering files (global preferences, not project-specific — \
105+
usually already embedded in your system prompt; load any whose content \
106+
you have not seen):\n{}",
106107
self.user.join("\n")
107108
));
108109
}
@@ -170,6 +171,123 @@ fn home_dir() -> Option<PathBuf> {
170171
std::env::var_os("HOME").map(PathBuf::from)
171172
}
172173

174+
/// Per-file budget (bytes, cut on a char boundary) for embedding a global
175+
/// steering file into the system prompt.
176+
pub const EMBED_PER_FILE_BUDGET: usize = 8 * 1024;
177+
/// Total budget (bytes) for all embedded global steering files combined.
178+
/// Kept below the daemon's per-server fragment budget (16 KiB) so the
179+
/// fragment is never truncated mid-file by the runtime.
180+
pub const EMBED_TOTAL_BUDGET: usize = 14 * 1024;
181+
182+
/// Whether a global steering file's *content* should be embedded into the
183+
/// system prompt. Prose instruction files are embedded; structured config
184+
/// (e.g. `settings.json`) is not — such files are not instructions and may
185+
/// contain tokens/secrets. Non-embedded files remain discoverable via
186+
/// `list_steering` / `load_steering`.
187+
fn is_embeddable(rel: &str) -> bool {
188+
!rel.ends_with(".json")
189+
}
190+
191+
/// Build the system-prompt fragment embedding the user's GLOBAL (home
192+
/// directory) steering files. Project files are excluded: they depend on a
193+
/// repository that may not be cloned yet, and remain on the
194+
/// `list_steering`/`load_steering` flow.
195+
pub async fn build_global_steering_prompt() -> Option<String> {
196+
build_global_steering_prompt_from(&home_dir()?).await
197+
}
198+
199+
/// [`build_global_steering_prompt`] with an explicit home directory (for
200+
/// tests).
201+
///
202+
/// Files are embedded in sorted path order, each delimited with its `~/`
203+
/// path. [`EMBED_PER_FILE_BUDGET`] and [`EMBED_TOTAL_BUDGET`] bound the
204+
/// fragment; truncated or skipped files get a marker telling the agent to
205+
/// read the rest via `load_steering`. Returns `None` when there is nothing
206+
/// to embed.
207+
pub async fn build_global_steering_prompt_from(home: &Path) -> Option<String> {
208+
let canon_home = fs::canonicalize(home).await.ok()?;
209+
210+
let mut seen_canonical = HashSet::new();
211+
let mut files: Vec<String> = Vec::new();
212+
for rel in collect_candidates(home).await {
213+
let abs = home.join(&rel);
214+
if !abs.exists() {
215+
continue;
216+
}
217+
let Ok(canonical) = fs::canonicalize(&abs).await else {
218+
continue;
219+
};
220+
if !canonical.starts_with(&canon_home) {
221+
continue;
222+
}
223+
if seen_canonical.insert(canonical) {
224+
files.push(rel.to_string_lossy().into_owned());
225+
}
226+
}
227+
files.sort();
228+
229+
let mut sections: Vec<String> = Vec::new();
230+
let mut used = 0usize;
231+
let mut deferred: Vec<String> = Vec::new();
232+
for rel in &files {
233+
if !is_embeddable(rel) {
234+
continue;
235+
}
236+
let Ok(content) = fs::read_to_string(home.join(rel)).await else {
237+
continue;
238+
};
239+
let content = content.trim();
240+
if content.is_empty() {
241+
continue;
242+
}
243+
if used >= EMBED_TOTAL_BUDGET {
244+
deferred.push(format!("~/{rel}"));
245+
continue;
246+
}
247+
let remaining = (EMBED_TOTAL_BUDGET - used).min(EMBED_PER_FILE_BUDGET);
248+
let (body, truncated) = if content.len() > remaining {
249+
let mut cut = remaining;
250+
while !content.is_char_boundary(cut) {
251+
cut -= 1;
252+
}
253+
(&content[..cut], true)
254+
} else {
255+
(content, false)
256+
};
257+
used += body.len();
258+
let mut section = format!("### ~/{rel}\n\n{body}");
259+
if truncated {
260+
section.push_str(&format!(
261+
"\n\n[... truncated — read the rest with `load_steering` (path: \"~/{rel}\")]"
262+
));
263+
}
264+
sections.push(section);
265+
}
266+
267+
if sections.is_empty() {
268+
return None;
269+
}
270+
271+
let mut prompt = String::from(
272+
"The user's global steering files (apply to ALL projects) are embedded \
273+
below. Follow them without being asked. Project-specific steering files \
274+
are NOT embedded — discover them with `list_steering` after cloning.\n",
275+
);
276+
for section in sections {
277+
prompt.push('\n');
278+
prompt.push_str(&section);
279+
prompt.push('\n');
280+
}
281+
if !deferred.is_empty() {
282+
prompt.push_str(&format!(
283+
"\nAdditional global steering files did not fit the embedding budget — \
284+
read them with `load_steering`: {}\n",
285+
deferred.join(", ")
286+
));
287+
}
288+
Some(prompt)
289+
}
290+
173291
/// Check that `rel` is under a known steering location.
174292
fn is_known_steering_location(rel: &str) -> bool {
175293
let rel_path = Path::new(rel);
@@ -238,7 +356,83 @@ mod tests {
238356
let rendered = files.render();
239357
assert!(rendered.contains("Project steering files:\nAGENTS.md"));
240358
assert!(rendered.contains("not project-specific"));
241-
assert!(rendered.contains("you should read these"));
359+
assert!(rendered.contains("load any whose content"));
242360
assert!(rendered.contains("~/INFINITY.md"));
243361
}
362+
363+
#[tokio::test]
364+
async fn global_prompt_embeds_only_global_prose_files() {
365+
let home = tempfile::tempdir().expect("create fake home");
366+
std::fs::write(home.path().join("INFINITY.md"), "Global rule: be kind.")
367+
.expect("write INFINITY.md");
368+
std::fs::create_dir_all(home.path().join(".kiro/steering")).expect("mkdir steering");
369+
std::fs::write(
370+
home.path().join(".kiro/steering/security.md"),
371+
"Never commit secrets.",
372+
)
373+
.expect("write steering file");
374+
std::fs::create_dir_all(home.path().join(".claude")).expect("mkdir .claude");
375+
std::fs::write(
376+
home.path().join(".claude/settings.json"),
377+
r#"{"token": "SECRET"}"#,
378+
)
379+
.expect("write settings.json");
380+
381+
let prompt = build_global_steering_prompt_from(home.path())
382+
.await
383+
.expect("globals exist, prompt builds");
384+
assert!(prompt.contains("### ~/INFINITY.md"));
385+
assert!(prompt.contains("Global rule: be kind."));
386+
assert!(prompt.contains("### ~/.kiro/steering/security.md"));
387+
assert!(prompt.contains("Never commit secrets."));
388+
// Structured config is never embedded (may hold secrets).
389+
assert!(!prompt.contains("settings.json"));
390+
assert!(!prompt.contains("SECRET"));
391+
// Project discovery guidance is present.
392+
assert!(prompt.contains("list_steering"));
393+
}
394+
395+
#[tokio::test]
396+
async fn global_prompt_is_none_without_steering_files() {
397+
let home = tempfile::tempdir().expect("create fake home");
398+
assert_eq!(build_global_steering_prompt_from(home.path()).await, None);
399+
}
400+
401+
#[tokio::test]
402+
async fn global_prompt_applies_budgets_with_markers() {
403+
let home = tempfile::tempdir().expect("create fake home");
404+
// One file over the per-file budget...
405+
std::fs::write(
406+
home.path().join("INFINITY.md"),
407+
"x".repeat(EMBED_PER_FILE_BUDGET + 100),
408+
)
409+
.expect("write big file");
410+
// ...and enough others to blow the total budget.
411+
std::fs::create_dir_all(home.path().join(".kiro/steering")).expect("mkdir steering");
412+
for i in 0..4 {
413+
std::fs::write(
414+
home.path().join(format!(".kiro/steering/f{i}.md")),
415+
"y".repeat(4 * 1024),
416+
)
417+
.expect("write filler");
418+
}
419+
420+
let prompt = build_global_steering_prompt_from(home.path())
421+
.await
422+
.expect("prompt builds");
423+
assert!(
424+
prompt.contains("[... truncated — read the rest with `load_steering`"),
425+
"per-file truncation marker missing"
426+
);
427+
assert!(
428+
prompt.contains("did not fit the embedding budget"),
429+
"total-budget deferral note missing"
430+
);
431+
// The fragment stays under the daemon's 16 KiB per-server budget.
432+
assert!(
433+
prompt.len() < 16 * 1024,
434+
"fragment too large: {}",
435+
prompt.len()
436+
);
437+
}
244438
}

‎crates/rap-steering-server/src/main.rs‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,17 @@ struct AppState {
2626
callback_client: PlainCallbackClient,
2727
}
2828

29-
fn build_manifest(endpoint: &str) -> ToolsetManifest {
29+
fn build_manifest(endpoint: &str, system_prompt: Option<String>) -> ToolsetManifest {
3030
ToolsetManifest {
3131
name: "steering-tools".to_owned(),
3232
description: Some("Discover and load project steering files (CLAUDE.md, AGENTS.md, .kiro/steering/, etc.)".to_owned()),
3333
endpoint: endpoint.to_owned(),
3434
needs_migration: false,
35-
system_prompt: None,
35+
system_prompt,
3636
tools: vec![
3737
ToolDef {
3838
name: "list_steering".to_owned(),
39-
description: "List all known steering/instruction files found in a project root. Call this immediately at the start of a session, before doing any other work, so the user's instructions can guide the rest of the task. Scans for CLAUDE.md, AGENTS.md, .kiro/steering/, .cursorrules, .github/copilot-instructions.md, .windsurfrules, CONVENTIONS.md, .cursor/rules/, .ai/rules/, etc. Also scans the user's home directory for user-level (non-project) steering files, returned with a `~/` prefix — these contain the user's global preferences and should generally be loaded. Returns deduplicated relative paths (symlinks resolved).".to_owned(),
39+
description: "List all known steering/instruction files found in a project root. Call this immediately after cloning a repository, before doing any other work, so the project's instructions can guide the rest of the task. Scans for CLAUDE.md, AGENTS.md, .kiro/steering/, .cursorrules, .github/copilot-instructions.md, .windsurfrules, CONVENTIONS.md, .cursor/rules/, .ai/rules/, etc. Also lists the user's home-directory steering files with a `~/` prefix — their content is usually already embedded in your system prompt; load any that are not. Returns deduplicated relative paths (symlinks resolved).".to_owned(),
4040
input_schema: serde_json::json!({
4141
"type": "object",
4242
"properties": {
@@ -84,7 +84,13 @@ async fn toolset_handler(headers: HeaderMap) -> Json<ToolsetManifest> {
8484
} else {
8585
"https"
8686
};
87-
Json(build_manifest(&format!("{scheme}://{host}/invoke")))
87+
// Rebuilt per fetch, so a new session picks up edits to the user's
88+
// global steering files without a server restart.
89+
let system_prompt = rap_steering_server::build_global_steering_prompt().await;
90+
Json(build_manifest(
91+
&format!("{scheme}://{host}/invoke"),
92+
system_prompt,
93+
))
8894
}
8995

9096
async fn invoke_handler(

0 commit comments

Comments
 (0)