From 7854fbd78adea5d5ab899f3d0f4706c352f963fa Mon Sep 17 00:00:00 2001 From: dave Date: Wed, 13 May 2026 14:08:58 +0000 Subject: [PATCH] huskies: merge 979 --- server/src/agents/local_prompt.rs | 159 +++++++++++++++++++++++++- server/src/agents/pool/start/spawn.rs | 8 -- 2 files changed, 157 insertions(+), 10 deletions(-) diff --git a/server/src/agents/local_prompt.rs b/server/src/agents/local_prompt.rs index 6418b82a..e848617e 100644 --- a/server/src/agents/local_prompt.rs +++ b/server/src/agents/local_prompt.rs @@ -10,6 +10,7 @@ //! - `.huskies/README.md` //! - `.huskies/specs/00_CONTEXT.md` //! - `.huskies/AGENT.md` +//! - `.huskies/source-map.json` (up to 200 KB; truncated with a log if larger) //! //! `STACK.md` is intentionally excluded — it is large and changes often; agents //! should grep it on demand. @@ -32,6 +33,12 @@ const ORIENTATION_FILES: &[&str] = &[ ".huskies/AGENT.md", ]; +/// Path to the source map (relative to project root), appended after AGENT.md. +const SOURCE_MAP_REL: &str = ".huskies/source-map.json"; + +/// Maximum bytes of source-map content to embed in the prompt. +const SOURCE_MAP_BYTE_CAP: usize = 200 * 1024; + /// Attempt to load the project-local agent prompt by concatenating orientation /// files from the project root. /// @@ -53,11 +60,14 @@ pub fn read_project_local_prompt(project_root: &Path) -> Option { sections.push((rel_path, trimmed.to_string())); } - if sections.is_empty() { + // Read source-map.json (after AGENT.md) with a byte cap. + let source_map_content = read_source_map_section(project_root); + + if sections.is_empty() && source_map_content.is_none() { return None; } - let included_files: Vec<&str> = sections.iter().map(|(name, _)| *name).collect(); + let mut included_files: Vec<&str> = sections.iter().map(|(name, _)| *name).collect(); let mut bundle = String::new(); for (i, (name, content)) in sections.iter().enumerate() { if i > 0 { @@ -67,6 +77,15 @@ pub fn read_project_local_prompt(project_root: &Path) -> Option { bundle.push_str(content); } + if let Some(sm) = source_map_content { + if !bundle.is_empty() { + bundle.push('\n'); + } + bundle.push_str(&format!("=== {SOURCE_MAP_REL} ===\n")); + bundle.push_str(&sm); + included_files.push(SOURCE_MAP_REL); + } + crate::slog!( "[agents] orientation bundle: {} bytes, files: [{}]", bundle.len(), @@ -76,6 +95,39 @@ pub fn read_project_local_prompt(project_root: &Path) -> Option { Some(bundle) } +/// Read `.huskies/source-map.json` from `project_root`, applying a byte cap. +/// +/// Returns `None` when the file is absent, unreadable, or empty. +/// When the content exceeds [`SOURCE_MAP_BYTE_CAP`], truncates at a char +/// boundary and logs the truncation. +#[allow(clippy::string_slice)] // cap is walked back to a char boundary before slicing +fn read_source_map_section(project_root: &Path) -> Option { + let path = project_root.join(SOURCE_MAP_REL); + let Ok(content) = std::fs::read_to_string(&path) else { + return None; + }; + let trimmed = content.trim(); + if trimmed.is_empty() { + return None; + } + if trimmed.len() > SOURCE_MAP_BYTE_CAP { + let mut cap = SOURCE_MAP_BYTE_CAP; + while cap > 0 && !trimmed.is_char_boundary(cap) { + cap -= 1; + } + crate::slog!( + "[agents] source-map.json truncated: {} bytes > {} byte cap; \ + including first {} bytes", + trimmed.len(), + SOURCE_MAP_BYTE_CAP, + cap + ); + Some(trimmed[..cap].to_string()) + } else { + Some(trimmed.to_string()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -257,4 +309,107 @@ mod tests { "STACK.md must not appear in bundle: {result}" ); } + + // ── source-map.json tests ──────────────────────────────────────────────── + + #[test] + fn source_map_included_after_agent_md() { + let tmp = tempfile::tempdir().unwrap(); + write_file(tmp.path(), ".huskies/AGENT.md", "agent content"); + write_file( + tmp.path(), + ".huskies/source-map.json", + r#"{"src/lib.rs": {"symbols": ["foo"]}}"#, + ); + + let result = read_project_local_prompt(tmp.path()).unwrap(); + assert!( + result.contains("=== .huskies/source-map.json ==="), + "source-map delimiter must be present: {result}" + ); + assert!( + result.contains(r#""src/lib.rs""#), + "source-map content must be present: {result}" + ); + // source-map section must appear after AGENT.md section + let agent_pos = result.find("=== .huskies/AGENT.md ===").unwrap(); + let sm_pos = result.find("=== .huskies/source-map.json ===").unwrap(); + assert!( + sm_pos > agent_pos, + "source-map section must come after AGENT.md section" + ); + } + + #[test] + fn source_map_missing_skipped_silently() { + let tmp = tempfile::tempdir().unwrap(); + write_file(tmp.path(), ".huskies/AGENT.md", "agent content"); + // source-map.json intentionally absent + + let result = read_project_local_prompt(tmp.path()).unwrap(); + assert!( + !result.contains("source-map.json"), + "absent source-map must not create a section: {result}" + ); + } + + #[test] + fn source_map_empty_skipped_silently() { + let tmp = tempfile::tempdir().unwrap(); + write_file(tmp.path(), ".huskies/AGENT.md", "agent content"); + write_file(tmp.path(), ".huskies/source-map.json", ""); + + let result = read_project_local_prompt(tmp.path()).unwrap(); + assert!( + !result.contains("source-map.json"), + "empty source-map must not create a section: {result}" + ); + } + + #[test] + fn source_map_only_returns_some() { + let tmp = tempfile::tempdir().unwrap(); + // Only source-map.json present; all orientation files absent. + write_file( + tmp.path(), + ".huskies/source-map.json", + r#"{"src/main.rs": {}}"#, + ); + + let result = read_project_local_prompt(tmp.path()); + assert!( + result.is_some(), + "source-map alone must produce Some bundle" + ); + assert!( + result.unwrap().contains("=== .huskies/source-map.json ==="), + "bundle must contain source-map section" + ); + } + + #[test] + #[allow(clippy::string_slice)] // sm_start is derived from str::find — always a char boundary + fn source_map_truncated_at_byte_cap() { + let tmp = tempfile::tempdir().unwrap(); + write_file(tmp.path(), ".huskies/AGENT.md", "agent"); + // Build content larger than SOURCE_MAP_BYTE_CAP (200 KB). + let big = "x".repeat(SOURCE_MAP_BYTE_CAP + 1024); + write_file(tmp.path(), ".huskies/source-map.json", &big); + + let result = read_project_local_prompt(tmp.path()).unwrap(); + assert!( + result.contains("=== .huskies/source-map.json ==="), + "truncated source-map must still produce a section: {result}" + ); + // The content length of just the source-map section must be <= SOURCE_MAP_BYTE_CAP. + let sm_start = result.find("=== .huskies/source-map.json ===").unwrap() + + "=== .huskies/source-map.json ===\n".len(); + let sm_content = &result[sm_start..]; + assert!( + sm_content.len() <= SOURCE_MAP_BYTE_CAP, + "source-map section content must be <= {} bytes, got {}", + SOURCE_MAP_BYTE_CAP, + sm_content.len() + ); + } } diff --git a/server/src/agents/pool/start/spawn.rs b/server/src/agents/pool/start/spawn.rs index 13a1fa66..acf66c9c 100644 --- a/server/src/agents/pool/start/spawn.rs +++ b/server/src/agents/pool/start/spawn.rs @@ -284,14 +284,6 @@ pub(super) async fn run_agent_spawn( prompt = format!("{block}\n\n{prompt}"); } - // Append a reference to the source map if the file was written. - let source_map_path = project_root_clone.join(".huskies").join("source-map.json"); - if source_map_path.exists() { - prompt.push_str( - "\n\nA source map of well-documented changed files is at `.huskies/source-map.json`.", - ); - } - match &session_id_to_resume_owned { Some(sess_id) => slog!("[agent:{sid}:{aname}] spawn mode=warm session_id={sess_id}"), None => slog!("[agent:{sid}:{aname}] spawn mode=cold"),