feat(project-context): merge global AGENTS.md with project AGENTS.md (#1157)

travel into every session, ideally merged with a project's local
AGENTS.md when both exist. Maintainer agreed:

> yes that makes sense! am working on getting this organizational
> structure better today so that worktrees etc can feel like an
> intended way of using this.

The fallback path already loaded the global file when no workspace
context existed, but dropped it silently the moment a project
AGENTS.md showed up. After this PR:

* Both files present → merged. The global block is prepended with a
  labelled HTML-style fence (`<!-- global: /home/u/.deepseek/AGENTS.md -->`),
  then the project block follows with its own fence
  (`<!-- project (overrides global where they conflict) -->`). Order
  is global-first so workspace rules read last and win "last word"
  precedence with the model when they disagree.
* Only project file present → unchanged from before.
* Only global file present → unchanged from before (still acts as a
  fallback). The merge framing is suppressed in the global-only case
  so the prompt stays minimal.

`source_path` continues to point at the more-specific file (project
> global > nothing) because that's the path the user is likely to
edit when they want to override something.

Two tests:
* `test_local_and_global_agents_merge_when_both_exist` —
  the actual #1157 scenario. Asserts both blocks are present, global
  precedes project, and the merge-framing label appears between them.
* `test_global_agents_only_no_project_unchanged_fallback` — sanity
  check that the global-only path doesn't accidentally inherit the
  merge framing.

The pre-existing `test_load_global_agents_when_project_has_no_context`
still passes, so the global-as-fallback contract is preserved.

Refs #1157
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
LinQ
2026-05-11 00:28:53 +01:00
committed by Hunter Bown
parent 29a42ba31a
commit a5c4a21c9b
+97 -9
View File
@@ -381,13 +381,32 @@ fn load_project_context_with_parents_and_home(
}
}
if !ctx.has_instructions()
&& let Some(global_ctx) = load_global_agents_context(workspace, home_dir)
{
// Always check `~/.deepseek/AGENTS.md` so user-wide preferences
// travel into every session (#1157). When both global and project
// instructions exist, the global block prepends the project's so
// workspace overrides win the last word; when only global exists,
// it continues to serve as the fallback. `source_path` keeps
// pointing at the more-specific source (project > global) for
// display purposes.
if let Some(global_ctx) = load_global_agents_context(workspace, home_dir) {
ctx.warnings.extend(global_ctx.warnings.iter().cloned());
if global_ctx.has_instructions() {
ctx.instructions = global_ctx.instructions;
ctx.source_path = global_ctx.source_path;
if let Some(global_text) = global_ctx.instructions {
match ctx.instructions.take() {
Some(project_text) => {
ctx.instructions = Some(merge_global_and_project_instructions(
&global_text,
global_ctx.source_path.as_deref(),
&project_text,
));
// Leave `ctx.source_path` pointing at the project /
// parent file — that's the location the user might
// want to edit when something looks wrong.
}
None => {
ctx.instructions = Some(global_text);
ctx.source_path = global_ctx.source_path;
}
}
}
}
@@ -412,6 +431,27 @@ fn load_project_context_with_parents_and_home(
ctx
}
/// Combine `~/.deepseek/AGENTS.md` (global, user-wide preferences) with a
/// project-local AGENTS.md/CLAUDE.md/instructions.md. Global comes first
/// so workspace-specific rules can override it — the model reads in
/// declared order. Each block is wrapped in a labelled fence so the
/// model can tell which level any rule comes from when the two sets
/// disagree (#1157).
fn merge_global_and_project_instructions(
global: &str,
global_source: Option<&Path>,
project: &str,
) -> String {
let global_label = global_source
.map(|p| format!("<!-- global: {} -->", p.display()))
.unwrap_or_else(|| "<!-- global -->".to_string());
format!(
"{global_label}\n{}\n\n<!-- project (overrides global where they conflict) -->\n{}",
global.trim_end(),
project.trim_start(),
)
}
fn load_global_agents_context(workspace: &Path, home_dir: Option<&Path>) -> Option<ProjectContext> {
let home = home_dir?;
let mut path = home.to_path_buf();
@@ -847,7 +887,10 @@ mod tests {
}
#[test]
fn test_local_agents_takes_priority_over_global_agents() {
fn test_local_and_global_agents_merge_when_both_exist() {
// #1157: when both `~/.deepseek/AGENTS.md` and a project AGENTS.md
// exist, the prompt should carry user-wide preferences AND the
// project's overrides — not silently drop the global file.
let workspace = tempdir().expect("workspace tempdir");
fs::write(workspace.path().join("AGENTS.md"), "Local instructions")
.expect("write local agents");
@@ -862,11 +905,56 @@ mod tests {
assert!(ctx.has_instructions());
let instructions = ctx.instructions.as_ref().unwrap();
assert!(instructions.contains("Local instructions"));
assert!(!instructions.contains("Global instructions"));
assert!(
instructions.contains("Global instructions"),
"global block missing from merged instructions:\n{instructions}"
);
assert!(
instructions.contains("Local instructions"),
"project block missing from merged instructions:\n{instructions}"
);
// Global block precedes the project block so project rules read
// last and win "last word" precedence with the model.
let global_at = instructions.find("Global instructions").unwrap();
let local_at = instructions.find("Local instructions").unwrap();
assert!(
global_at < local_at,
"global block must come before project block, got global={global_at} local={local_at}"
);
// The merged block is labelled so the model can tell the layers
// apart when it needs to explain which rule it followed.
assert!(
instructions.contains("project (overrides global where they conflict)"),
"expected labelled separator between global and project blocks"
);
// `source_path` keeps pointing at the more-specific file so the
// user knows where to edit the workspace-level override.
assert_eq!(ctx.source_path, Some(workspace.path().join("AGENTS.md")));
}
#[test]
fn test_global_agents_only_no_project_unchanged_fallback() {
// Sanity: when only the global file exists, the historical
// fallback behaviour is preserved — no merge framing leaks in.
let workspace = tempdir().expect("workspace tempdir");
let home = tempdir().expect("home tempdir");
let global_dir = home.path().join(".deepseek");
fs::create_dir(&global_dir).expect("mkdir .deepseek");
let global_agents = global_dir.join("AGENTS.md");
fs::write(&global_agents, "Just the global instructions").expect("write global agents");
let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
assert!(ctx.has_instructions());
let instructions = ctx.instructions.as_ref().unwrap();
assert!(instructions.contains("Just the global instructions"));
assert!(
!instructions.contains("project (overrides global"),
"merge-framing label should not appear when there's nothing to merge"
);
assert_eq!(ctx.source_path, Some(global_agents));
}
#[test]
fn test_invalid_global_agents_warns_and_falls_back_to_generated_context() {
let workspace = tempdir().expect("workspace tempdir");